hecks 3.2.1 → 3.4.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 (120) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/bluebook/assembly/contracts.rb +2 -1
  3. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +1 -0
  4. data/lib/hecks/bluebook/dsl/command_builder.rb +2 -39
  5. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +21 -23
  6. data/lib/hecks/bluebook/dsl/need_word.rb +50 -0
  7. data/lib/hecks/bluebook/dsl/query_builder.rb +6 -1
  8. data/lib/hecks/bluebook/query.rb +8 -5
  9. data/lib/hecks/cli/interview_draft.rb +88 -33
  10. data/lib/hecks/cli/interview_session.rb +77 -26
  11. data/lib/hecks/cli/interview_shape.rb +198 -0
  12. data/lib/hecks/corpus.rb +3 -5
  13. data/lib/hecks/doors/cli_runner.rb +14 -1
  14. data/lib/hecks/doors/launcher_options.rb +13 -3
  15. data/lib/hecks/doors/mcp_door_scope.rb +2 -1
  16. data/lib/hecks/doors.rb +0 -17
  17. data/lib/hecks/embryonaut_bluebook/manifest.rb +117 -0
  18. data/lib/hecks/embryonaut_bluebook/registry.rb +209 -0
  19. data/lib/hecks/embryonaut_bluebook/vendor.rb +1 -1
  20. data/lib/hecks/embryonaut_bluebook.rb +2 -0
  21. data/lib/hecks/fuzzing/isolated_boot.rb +1 -4
  22. data/lib/hecks/fuzzing/qa_settings.rb +15 -34
  23. data/lib/hecks/fuzzing/target_capabilities.rb +1 -1
  24. data/lib/hecks/gate/stages.yml +113 -8
  25. data/lib/hecks/hecks/adapters/codebase/regeneration.rb +10 -1
  26. data/lib/hecks/hecks/adapters/first_admin.rb +108 -0
  27. data/lib/hecks/hecks/adapters/git.rb +59 -0
  28. data/lib/hecks/hecks/adapters/in_process_access.rb +25 -0
  29. data/lib/hecks/hecks/adapters/in_process_boot.rb +2 -0
  30. data/lib/hecks/hecks/adapters/rust_toolchain.rb +18 -3
  31. data/lib/hecks/hecks/adapters/site_toolchain.rb +67 -0
  32. data/lib/hecks/hecks/codebase.behaviors +6 -0
  33. data/lib/hecks/hecks/codebase.bluebook +21 -0
  34. data/lib/hecks/hecks/custodian.behaviors +6 -0
  35. data/lib/hecks/hecks/custodian.bluebook +217 -0
  36. data/lib/hecks/hecks/hecks.hecksagon +24 -0
  37. data/lib/hecks/hecks/hecks.world +8 -3
  38. data/lib/hecks/hecks/site.world +5 -2
  39. data/lib/hecks/language/bluebook/query.bluebook +25 -0
  40. data/lib/hecks/language/bluebook/vocabulary.bluebook +213 -0
  41. data/lib/hecks/language/hecksagon/hecksagon.bluebook +0 -5
  42. data/lib/hecks/language/oidc.json +5 -0
  43. data/lib/hecks/projections/deploy/box/hosting.rb +170 -0
  44. data/lib/hecks/projections/deploy/box/hosting_settings.rb +123 -0
  45. data/lib/hecks/projections/deploy/box/settings.rb +17 -4
  46. data/lib/hecks/projections/deploy/box/templates/deploy-service.services-roll.part +33 -0
  47. data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +79 -0
  48. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef-roll.part +77 -0
  49. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef.part +12 -0
  50. data/lib/hecks/projections/deploy/box/templates/hosting.mk.tmpl +24 -0
  51. data/lib/hecks/projections/deploy/box/templates/smoke-after-deploy.sh.tmpl +271 -0
  52. data/lib/hecks/projections/deploy/box.rb +15 -2
  53. data/lib/hecks/projections/deploy/fargate.rb +3 -3
  54. data/lib/hecks/projections/deploy/lambda.rb +8 -8
  55. data/lib/hecks/projections/model/deviations.rb +1 -1
  56. data/lib/hecks/projections/site/live_edge.rb +162 -0
  57. data/lib/hecks/quality_control/adapters/git_pr.rb +0 -14
  58. data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +1 -1
  59. data/lib/hecks/quality_control/cli/qa_open_pr.rb +32 -16
  60. data/lib/hecks/quality_control/cli/qa_seed_angles.rb +2 -2
  61. data/lib/hecks/quality_control/cli/qa_seed_targets.rb +1 -2
  62. data/lib/hecks/quality_control/quality_control.bluebook +76 -124
  63. data/lib/hecks/runtime/interpreting.rb +3 -22
  64. data/lib/hecks/runtime/loader.rb +6 -7
  65. data/lib/hecks/runtime/needs.rb +48 -0
  66. data/lib/hecks/runtime/query_interpreter.rb +3 -0
  67. data/lib/hecks/runtime/registry/verification.rb +3 -3
  68. data/lib/hecks/runtime.rb +6 -11
  69. data/lib/hecks/rust_build/host.rb +147 -0
  70. data/lib/hecks/rust_build/kernel_input.rb +40 -18
  71. data/lib/hecks/rust_build/project_rust/codegen_run.rb +0 -18
  72. data/lib/hecks/rust_build/project_rust.rb +3 -10
  73. data/lib/hecks/rust_build.rb +1 -0
  74. data/lib/hecks/site/bluebook/site.bluebook +53 -1
  75. data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
  76. data/lib/hecks/sme/bluebook/sme.bluebook +128 -1
  77. data/lib/hecks/tools/gate.rb +1 -1
  78. data/lib/hecks/tools/rust_comment_style.rb +1 -3
  79. data/lib/hecks/tools/tools_doc.rb +204 -0
  80. data/lib/hecks/tools.rb +1 -0
  81. data/lib/hecks/version.rb +1 -1
  82. data/lib/hecks/vocabulary.rb +151 -0
  83. data/lib/hecks.rb +6 -12
  84. data/qa/settings.yml +1 -2
  85. data/rust/build/src/lineage_pass.rs +30 -15
  86. data/rust/build/src/main.rs +4 -2
  87. data/rust/build/src/pipeline.rs +8 -19
  88. data/rust/codegen/src/bridging.rs +7 -7
  89. data/rust/codegen/src/json.rs +214 -23
  90. data/rust/codegen/src/literal.rs +6 -3
  91. data/rust/codegen/src/main.rs +20 -9
  92. data/rust/codegen/src/naming.rs +4 -2
  93. data/rust/codegen/src/optional_pass.rs +226 -0
  94. data/rust/{build → codegen}/src/sidecars.rs +9 -5
  95. data/rust/host/HECKS_RELEASE +1 -1
  96. data/rust/host/src/api.rs +1 -1
  97. data/rust/host/src/dispatch.rs +5 -0
  98. data/rust/host/src/needs.rs +99 -2
  99. data/rust/host/src/resend.rs +4 -4
  100. data/rust/host/src/ui_schema.rs +1 -1
  101. data/rust/parser/src/emit.rs +12 -0
  102. data/rust/parser/src/ir.rs +2 -0
  103. data/rust/parser/src/keywords.rs +2 -4
  104. data/rust/parser/src/lex.rs +1 -1
  105. data/rust/parser/src/main.rs +1 -0
  106. data/rust/parser/src/parse/command.rs +2 -44
  107. data/rust/parser/src/parse/hecksagon.rs +15 -24
  108. data/rust/parser/src/parse/mod.rs +1 -0
  109. data/rust/parser/src/parse/needs.rs +60 -0
  110. data/rust/parser/src/parse/query.rs +3 -0
  111. data/rust/src/kernel/cli.rs +16 -6
  112. data/rust/src/kernel/needs.rs +104 -9
  113. metadata +22 -9
  114. data/lib/hecks/facade.rb +0 -31
  115. data/lib/hecks/rust_build/append_optionals.rb +0 -79
  116. data/lib/hecks/rust_build/rust_literal.rb +0 -33
  117. data/lib/hecks/three_zero/forms.yml +0 -127
  118. data/lib/hecks/three_zero.rb +0 -12
  119. data/rust/build/src/json.rs +0 -421
  120. data/rust/build/src/optional_pass.rs +0 -150
@@ -1,6 +1,6 @@
1
1
  require_relative "value"
2
2
  require_relative "aggregate_lock"
3
- require_relative "../ports/clock"
3
+ require_relative "needs"
4
4
 
5
5
  module Hecks
6
6
  module Runtime
@@ -14,11 +14,6 @@ module Hecks
14
14
  interpreter.singleton_class.attr_accessor :trace
15
15
  end
16
16
 
17
- # What answers each outside fact a command may `needs` (ADR 0081). A fact is answered once,
18
- # before any given runs, and the answer rides in the command's own arguments, so the event
19
- # records it and a replay re-dispatches the recorded value rather than asking again.
20
- NEED_ANSWERS = { now: ->(registry) { Ports::Clock.now(registry) } }.freeze
21
-
22
17
  private
23
18
 
24
19
  # Fills each outside fact the command `needs` and the caller left out. A value the caller
@@ -32,13 +27,8 @@ module Hecks
32
27
  fill_defaults(command, fill_needs(command, args))
33
28
  end
34
29
 
35
- # Answers each fact the command `needs` that the caller left out.
36
- def fill_needs(command, args)
37
- missing = command.needs.reject { |fact| args.key?(fact) || args.key?(fact.to_s) }
38
- return args if missing.empty?
39
-
40
- args.merge(missing.to_h { |fact| [fact, need_value(command, fact)] })
41
- end
30
+ # Answers each fact the command `needs` that the caller left out (see `Needs`).
31
+ def fill_needs(command, args) = Needs.fill(command, args, registry: @registry)
42
32
 
43
33
  # Gives each argument the caller left out the default its attribute declares
44
34
  # (`attribute :runs, Count, default: 30`), so a declared default holds on every way in and
@@ -56,15 +46,6 @@ module Hecks
56
46
  args.merge(absent.to_h { |attribute| [attribute.name.to_sym, attribute.default] })
57
47
  end
58
48
 
59
- # The answer to one fact, in the shape the command's argument of that name takes: a bare
60
- # Integer for an Integer attribute, else the one-field value object the language declares
61
- # for an instant.
62
- def need_value(command, fact)
63
- answer = NEED_ANSWERS.fetch(fact).call(@registry)
64
- attribute = command.attributes.find { |held| held.name.to_s == fact.to_s }
65
- attribute&.type.to_s == "Integer" ? answer : { value: answer }
66
- end
67
-
68
49
  # Logged after the step's work, so trace order is completion order.
69
50
  def step(name)
70
51
  result = yield
@@ -27,9 +27,9 @@ module Hecks
27
27
  # @return [Runtime::Dispatcher, Runtime::RemoteDispatcher] the booted dispatcher
28
28
  # @raise [Errno::ENOENT] if `path` names no domain directory
29
29
  # @raise [Runtime::WiringError] if a boot gate finds a wiring problem
30
- def self.boot(path, shared: nil, install_doors: true, install_facade: nil, environment: FROM_ENV)
30
+ def self.boot(path, shared: nil, install_doors: true, environment: FROM_ENV)
31
31
  described = describe(path, shared: shared, environment: environment)
32
- boot_described(described, install_doors: install_doors, install_facade: install_facade)
32
+ boot_described(described, install_doors: install_doors)
33
33
  end
34
34
 
35
35
  # Finishes a boot from declarations `describe` already loaded: runs every boot gate and binds
@@ -37,16 +37,15 @@ module Hecks
37
37
  #
38
38
  # @param described [Described] what `describe` answered for the domain to boot
39
39
  # @param install_doors [Boolean] install the `Widget::Item.Add(...)` global facade sugar
40
- # @param install_facade [Boolean, nil] the deprecated spelling of `install_doors`
41
40
  # @return [Runtime::Dispatcher, Runtime::RemoteDispatcher] the booted dispatcher
42
41
  # @raise [Runtime::WiringError] if a boot gate finds a wiring problem
43
- def self.boot_described(described, install_doors: true, install_facade: nil)
42
+ def self.boot_described(described, install_doors: true)
44
43
  registry = described.registry
45
44
  run_boot_gates!(registry, described.directory)
46
45
  dispatcher = dispatcher_for(registry)
47
46
  redrive_outbox!(dispatcher)
48
47
  seed_privacy_markings!(dispatcher, registry)
49
- Doors.install?(install_doors, install_facade) ? bind_runtime(dispatcher) : dispatcher
48
+ install_doors ? bind_runtime(dispatcher) : dispatcher
50
49
  end
51
50
 
52
51
  # What `describe` answers: the loaded declarations and nothing bound to run them.
@@ -153,7 +152,7 @@ module Hecks
153
152
  # defaults to `HECKS_ENVIRONMENT`, and an explicit nil loads none
154
153
  # @return [Runtime::Dispatcher, Runtime::RemoteDispatcher] the booted dispatcher
155
154
  # @raise [Runtime::WiringError] if a boot gate finds a wiring problem
156
- def self.boot_files(paths, shared: nil, install_doors: true, install_facade: nil, environment: FROM_ENV)
155
+ def self.boot_files(paths, shared: nil, install_doors: true, environment: FROM_ENV)
157
156
  loading = Ports::Loading.bootstrap
158
157
  files = Array(paths).map { |path| File.expand_path(path) }
159
158
  directory = File.dirname(files.first)
@@ -168,7 +167,7 @@ module Hecks
168
167
 
169
168
  run_boot_gates!(registry, directory)
170
169
  dispatcher = dispatcher_for(registry)
171
- Doors.install?(install_doors, install_facade) ? bind_runtime(dispatcher) : dispatcher
170
+ install_doors ? bind_runtime(dispatcher) : dispatcher
172
171
  end
173
172
 
174
173
  # Runs every registered boot gate against `registry`, in order:
@@ -0,0 +1,48 @@
1
+ require_relative "../ports/clock"
2
+
3
+ module Hecks
4
+ module Runtime
5
+ # Answers the outside facts a command or a query `needs` (ADR 0081). A fact is answered once,
6
+ # before any given or filter reads the arguments, and the answer rides in the declaration's own
7
+ # arguments, so an event records it and a replay re-dispatches the recorded value rather than
8
+ # asking again.
9
+ module Needs
10
+ # What answers each fact a declaration may need: `now` is the clock port's reading in epoch
11
+ # seconds, `today` the day it falls in, whole days since the epoch in UTC.
12
+ ANSWERS = {
13
+ now: ->(registry) { Ports::Clock.now(registry) },
14
+ today: ->(registry) { Ports::Clock.now(registry) / 86_400 }
15
+ }.freeze
16
+
17
+ module_function
18
+
19
+ # Fills each outside fact the declaration `needs` and the caller left out. A value the caller
20
+ # supplied is kept, so a test or a back-fill can name its own time.
21
+ #
22
+ # @param declaring [#needs, #attributes] the command or query being run
23
+ # @param args [Hash{Symbol => Object}] the arguments the caller passed
24
+ # @param registry [Runtime::Registry] where the clock port is bound
25
+ # @return [Hash{Symbol => Object}] `args`, with each missing needed fact answered
26
+ # @raise [Runtime::WiringError] when the port that answers a fact is not bound exactly once
27
+ def fill(declaring, args, registry:)
28
+ missing = declaring.needs.reject { |fact| args.key?(fact) || args.key?(fact.to_s) }
29
+ return args if missing.empty?
30
+
31
+ args.merge(missing.to_h { |fact| [fact, value(declaring, fact, registry)] })
32
+ end
33
+
34
+ # The answer to one fact, in the shape the declaration's argument of that name takes: a bare
35
+ # Integer for an Integer attribute, else the one-field value object the language declares.
36
+ #
37
+ # @param declaring [#attributes] the command or query
38
+ # @param fact [Symbol] a key of `ANSWERS`
39
+ # @param registry [Runtime::Registry] where the clock port is bound
40
+ # @return [Integer, Hash{Symbol => Integer}] the answer, shaped for its argument
41
+ def value(declaring, fact, registry)
42
+ answer = ANSWERS.fetch(fact).call(registry)
43
+ attribute = declaring.attributes.find { |held| held.name.to_s == fact.to_s }
44
+ attribute&.type.to_s == "Integer" ? answer : { value: answer }
45
+ end
46
+ end
47
+ end
48
+ end
@@ -5,6 +5,7 @@ require_relative "../ports/query/ordering"
5
5
  require_relative "../query_specification/field_path"
6
6
  require_relative "../query_specification/common/comparison"
7
7
  require_relative "errors"
8
+ require_relative "needs"
8
9
  require_relative "reference_hop"
9
10
  require_relative "refusal_wording"
10
11
  require_relative "tenant_scope"
@@ -203,6 +204,7 @@ module Hecks
203
204
  def entity_rows(domain, aggregate, dotted, args)
204
205
  entity_name, query_name = Naming.split_dotted(dotted)
205
206
  entity, declared, list_attr = resolve_entity_query(aggregate, entity_name, query_name)
207
+ args = Needs.fill(declared, args, registry: @registry)
206
208
  declared = TenantScope.apply(declared, args)
207
209
 
208
210
  parent_key = Naming.reference_key(aggregate.hecks_name)
@@ -282,6 +284,7 @@ module Hecks
282
284
  # from field defaults and only refuses when a field has none. `null_vo_argument!`
283
285
  # refuses like any other wrong-shaped value. Command arguments are untouched.
284
286
  def normalize_args(aggregate, declared, args)
287
+ args = Needs.fill(declared, args, registry: @registry)
285
288
  declared.attributes.each_with_object(args.dup) do |attribute, normalized|
286
289
  next unless normalized.key?(attribute.name)
287
290
 
@@ -408,9 +408,9 @@ module Hecks
408
408
  File.expand_path(path.to_s).start_with?("#{GEM_CHAPTER_DIR}/")
409
409
  end
410
410
 
411
- # Two packages can share a chapter name by coincidence (found live: a stale
412
- # vendor/hecksagain fork of Governance/Identity/Deploy, still on 4 apps' load
413
- # paths). Intentional accumulation (several files declaring the same chapter on
411
+ # Two packages can share a chapter name by coincidence (a stale vendored
412
+ # fork of Governance/Identity/Deploy still on an app's load paths).
413
+ # Intentional accumulation (several files declaring the same chapter on
414
414
  # purpose) is distinguished by package root, not file identity, and is left
415
415
  # untouched here — checked once, after every file has loaded.
416
416
  def refuse_cross_package_bluebook_merge!
data/lib/hecks/runtime.rb CHANGED
@@ -43,13 +43,11 @@ module Hecks
43
43
  # @param path [String] path to a domain directory, or a file inside one
44
44
  # @param shared [String, nil] a shared-root override
45
45
  # @param install_doors [Boolean] whether to install the Ruby facade constants
46
- # @param install_facade [Boolean, nil] the deprecated spelling of `install_doors`; warns
47
46
  # @param environment [String, nil] the environment name for `Adapters::Folder#load_domain`
48
47
  # @return [Runtime::Dispatcher, Runtime::RemoteDispatcher] the dispatcher bound
49
48
  # to the booted domain
50
- def boot(path, shared: nil, install_doors: true, install_facade: nil, environment: Runtime::Loader::FROM_ENV)
51
- Loader.boot(path, shared: shared, install_doors: install_doors, environment: environment,
52
- install_facade: install_facade)
49
+ def boot(path, shared: nil, install_doors: true, environment: Runtime::Loader::FROM_ENV)
50
+ Loader.boot(path, shared: shared, install_doors: install_doors, environment: environment)
53
51
  end
54
52
 
55
53
  # Loads a domain's declarations without binding any adapter. See Loader.describe.
@@ -66,11 +64,10 @@ module Hecks
66
64
  #
67
65
  # @param described [Runtime::Loader::Described] what `describe` answered
68
66
  # @param install_doors [Boolean] whether to install the Ruby facade constants
69
- # @param install_facade [Boolean, nil] the deprecated spelling of `install_doors`; warns
70
67
  # @return [Runtime::Dispatcher, Runtime::RemoteDispatcher] the dispatcher bound
71
68
  # to the booted domain
72
- def boot_described(described, install_doors: true, install_facade: nil)
73
- Loader.boot_described(described, install_doors: install_doors, install_facade: install_facade)
69
+ def boot_described(described, install_doors: true)
70
+ Loader.boot_described(described, install_doors: install_doors)
74
71
  end
75
72
 
76
73
  # Loads only the given files of a domain; otherwise like `boot`. See Loader.boot_files.
@@ -78,13 +75,11 @@ module Hecks
78
75
  # @param paths [String, Array<String>] one or more file paths within the domain
79
76
  # @param shared [String, nil] a shared-root override
80
77
  # @param install_doors [Boolean] whether to install the Ruby facade constants
81
- # @param install_facade [Boolean, nil] the deprecated spelling of `install_doors`; warns
82
78
  # @param environment [String, nil] the environment name for the selected-file loader
83
79
  # @return [Runtime::Dispatcher, Runtime::RemoteDispatcher] the dispatcher bound
84
80
  # to the booted domain
85
- def boot_files(paths, shared: nil, install_doors: true, install_facade: nil, environment: Runtime::Loader::FROM_ENV)
86
- Loader.boot_files(paths, shared: shared, install_doors: install_doors, environment: environment,
87
- install_facade: install_facade)
81
+ def boot_files(paths, shared: nil, install_doors: true, environment: Runtime::Loader::FROM_ENV)
82
+ Loader.boot_files(paths, shared: shared, install_doors: install_doors, environment: environment)
88
83
  end
89
84
 
90
85
  # Bind the ambient registry for the duration of the block, restoring
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "open3"
5
+ require_relative "../rust_build"
6
+ require_relative "wasm"
7
+
8
+ module Hecks
9
+ module RustBuild
10
+ # Builds the Rust host binary for a domain and stages it with the domain's module and IR, the
11
+ # three files a container image copies in.
12
+ #
13
+ # Host.call(["path/to/domain", "--target=aarch64-unknown-linux-gnu", "--stage=build/host"])
14
+ #
15
+ # The host is compiled from the workspace this release carries (`rust/host`), so the binary is
16
+ # the one the installed gem was released with; nothing is fetched. The module and IR come from
17
+ # `Wasm`. `CARGO_TARGET_DIR` names where Cargo writes (the copy's `target/` for a client), so
18
+ # a second build recompiles only what changed. Stages `<domain>-host`, `<domain>.wasm` and
19
+ # `<domain>.ir.json` in `--stage` (default `.hecks/host/<target>/` in the working directory).
20
+ module Host
21
+ # The host crate's binary.
22
+ BINARY = "bootstrap"
23
+
24
+ # The toolchain Cargo runs under, as `Wasm` builds with.
25
+ TOOLCHAIN = "stable"
26
+
27
+ # A Rust target triple: `<arch>-<vendor>-<os>`, with an optional `-<env>`.
28
+ TRIPLE = /\A[a-z0-9_]+(-[a-z0-9_.]+){2,3}\z/
29
+
30
+ module_function
31
+
32
+ # @param argv [Array<String>] the domain's directory, then optional `--target=<triple>` and
33
+ # `--stage=<directory>`
34
+ # @return [Integer] the exit status
35
+ # @raise [Failure] when the toolchain, the target or a step is missing or fails
36
+ def call(argv)
37
+ domain = argv.find { |arg| !arg.start_with?("--") } or
38
+ raise Failure, "usage: hecks build_host <domain> [target=<triple>] [stage=<directory>]"
39
+ target = flag(argv, "target") || host_triple
40
+ require_buildable!(target)
41
+ stage = flag(argv, "stage") || File.join(Dir.pwd, ".hecks", "host", target)
42
+
43
+ status = Wasm.call([domain])
44
+ raise Failure, "project_wasm failed for #{domain}" unless status.zero?
45
+
46
+ binary = compile(target)
47
+ publish(binary, File.basename(domain.chomp("/")), stage)
48
+ 0
49
+ end
50
+
51
+ # @param argv [Array<String>] a tool's arguments
52
+ # @param name [String] a flag's name
53
+ # @return [String, nil] the value of `--<name>=<value>`, nil when absent
54
+ def flag(argv, name)
55
+ argv.filter_map { |arg| arg.delete_prefix("--#{name}=") if arg.start_with?("--#{name}=") }.last
56
+ end
57
+
58
+ # @return [String] the triple the Rust toolchain builds for by default
59
+ # @raise [Failure] when there is no Rust toolchain
60
+ def host_triple
61
+ require_rustup!
62
+ query("rustup", "run", TOOLCHAIN, "rustc", "-vV").to_s[/^host: (\S+)/, 1] or
63
+ raise Failure, "rustup has no #{TOOLCHAIN} toolchain. Install it once with:\n\n " \
64
+ "rustup toolchain install #{TOOLCHAIN}\n\nthen re-run the build."
65
+ end
66
+
67
+ def require_buildable!(target)
68
+ raise Failure, "#{target} is not a Rust target triple (for example aarch64-unknown-linux-gnu)" unless
69
+ TRIPLE.match?(target)
70
+ if target.start_with?("wasm")
71
+ raise Failure, "the host is a server binary; #{target} is a wasm target. Build the module with " \
72
+ "build.build_wasm, or name a native target"
73
+ end
74
+
75
+ require_rustup!
76
+ installed = query("rustup", "target", "list", "--installed", "--toolchain", TOOLCHAIN).to_s.split
77
+ return if installed.include?(target) || target == host_triple
78
+
79
+ raise Failure, <<~MSG
80
+ #{target} isn't installed for the #{TOOLCHAIN} toolchain. Install it once with:
81
+
82
+ rustup target add #{target} --toolchain #{TOOLCHAIN}
83
+
84
+ then re-run the build.
85
+ MSG
86
+ end
87
+
88
+ def require_rustup!
89
+ return if query("rustup", "--version")
90
+
91
+ raise Failure, "rustup isn't installed. Install the Rust toolchain from https://rustup.rs, " \
92
+ "then re-run the build."
93
+ end
94
+
95
+ # `rustup run`, not bare `cargo`, as `Wasm` does. A cross target links with the
96
+ # `<arch>-linux-gnu-gcc` on PATH unless `CARGO_TARGET_<TRIPLE>_LINKER` already names a linker.
97
+ def compile(target)
98
+ host_dir = File.join(RustBuild.rust_dir, "host")
99
+ target_dir = ENV.fetch("CARGO_TARGET_DIR", File.join(host_dir, "target"))
100
+ puts "== cargo build --release --target #{target} --bin #{BINARY} (rust/host) =="
101
+ env = { "CARGO_TARGET_DIR" => target_dir }.merge(linker_for(target))
102
+ RustBuild.command!("rustup", "run", TOOLCHAIN, "cargo", "build", "--release", "--target", target,
103
+ "--bin", BINARY, chdir: host_dir, env: env)
104
+ File.join(target_dir, target, "release", BINARY)
105
+ rescue Failure => e
106
+ raise Failure, "#{e.message}#{cross_hint(target)}"
107
+ end
108
+
109
+ def linker_for(target)
110
+ variable = "CARGO_TARGET_#{target.upcase.tr('-.', '__')}_LINKER"
111
+ return {} if ENV.key?(variable) || target == host_triple
112
+
113
+ candidate = "#{target.split('-').first}-linux-gnu-gcc"
114
+ target.end_with?("linux-gnu") && query(candidate, "--version") ? { variable => candidate } : {}
115
+ end
116
+
117
+ def cross_hint(target)
118
+ return "" if target == host_triple
119
+
120
+ "\n#{target} differs from this machine's #{host_triple}, so linking needs a linker for it: " \
121
+ "put a cross compiler such as #{target.split('-').first}-linux-gnu-gcc on PATH, or set " \
122
+ "CARGO_TARGET_#{target.upcase.tr('-.', '__')}_LINKER"
123
+ end
124
+
125
+ def publish(binary, name, stage)
126
+ dist = File.join(RustBuild.rust_dir, "dist")
127
+ files = { binary => "#{name}-host", File.join(dist, "#{name}.wasm") => "#{name}.wasm",
128
+ File.join(dist, "#{name}.ir.json") => "#{name}.ir.json" }
129
+ files.each_key do |path|
130
+ raise Failure, "the build left no #{path}" unless File.file?(path)
131
+ end
132
+ FileUtils.mkdir_p(stage)
133
+ files.each { |from, to| FileUtils.cp(from, File.join(stage, to)) }
134
+ File.chmod(0o755, File.join(stage, "#{name}-host"))
135
+ puts "staged in #{stage}: #{files.values.join(', ')}"
136
+ end
137
+
138
+ # @return [String, nil] a program's combined output, nil when it is missing or fails
139
+ def query(*command)
140
+ out, status = Open3.capture2e(*command)
141
+ status.success? ? out : nil
142
+ rescue SystemCallError
143
+ nil
144
+ end
145
+ end
146
+ end
147
+ end
@@ -5,12 +5,14 @@ require_relative "../rust_build"
5
5
 
6
6
  module Hecks
7
7
  module RustBuild
8
- # The stdin a compiled domain binary reads: the steps to run, plus the declared argument
9
- # defaults the host would hand its kernel.
8
+ # The stdin a compiled domain binary reads: the steps to run, plus what the host would hand its
9
+ # kernel from the domain's IR.
10
10
  #
11
- # The kernel has no IR of its own, so a command's declared defaults reach it as a top-level
12
- # `"defaults"` table, `{ "Domain::Aggregate.Command" => { "attribute" => value } }`, the same
13
- # one the Rust host builds from the domain's IR. A harness that sends only `"steps"` would
11
+ # The kernel has no IR of its own, so the facts a declaration reaches it with arrive as
12
+ # top-level tables the Rust host builds from the domain's IR: `"defaults"`, a command's declared
13
+ # argument defaults (`{ "Domain::Aggregate.Command" => { "attribute" => value } }`); `"needs"`,
14
+ # the outside facts a command needs; and `"query_needs"`, the same for a query (kept apart: a
15
+ # command and a query may share a qualified name). A harness that sends only `"steps"` would
14
16
  # leave the kernel refusing an argument Ruby fills, so every conformance caller builds its
15
17
  # input here.
16
18
  module KernelInput
@@ -18,11 +20,10 @@ module Hecks
18
20
 
19
21
  # @param domain_path [String] the domain's directory; its basename names the generated module
20
22
  # @param steps [Array<Hash>] the steps to run
21
- # @return [Hash{String => Object}] the input; `"defaults"` is there only when one is declared
23
+ # @return [Hash{String => Object}] the input; each table is there only when it has an entry
22
24
  def build(domain_path, steps)
23
25
  input = { "steps" => steps }
24
- defaults = defaults_for(domain_path)
25
- input["defaults"] = defaults unless defaults.empty?
26
+ tables_for(domain_path).each { |name, table| input[name] = table unless table.empty? }
26
27
  input
27
28
  end
28
29
 
@@ -35,27 +36,48 @@ module Hecks
35
36
  #
36
37
  # @param domain_path [String] the domain's directory
37
38
  # @return [Hash{String => Hash{String => Object}}] empty when the IR is absent or has none
38
- def defaults_for(domain_path)
39
- ir = generated_ir(domain_path) or return {}
39
+ def defaults_for(domain_path) = tables_for(domain_path).fetch("defaults")
40
+
41
+ # The three tables of one domain: `"defaults"`, `"needs"` and `"query_needs"`.
42
+ #
43
+ # @param domain_path [String] the domain's directory
44
+ # @return [Hash{String => Hash}] each table, empty when the IR is absent or declares none
45
+ def tables_for(domain_path)
46
+ tables = { "defaults" => {}, "needs" => {}, "query_needs" => {} }
47
+ ir = generated_ir(domain_path) or return tables
40
48
  domain = ir["name"].to_s
41
- Array(ir["aggregates"]).each_with_object({}) do |aggregate, table|
42
- collect_defaults(aggregate, "#{domain}::#{aggregate['name']}", table)
43
- end
49
+ Array(ir["aggregates"]).each { |aggregate| collect(aggregate, "#{domain}::#{aggregate['name']}", tables) }
50
+ tables
44
51
  end
45
52
 
46
- # Adds the defaults of `node`'s commands, then of each entity nested in it, under `prefix`.
53
+ # Adds what `node`'s commands and queries declare, then what each entity nested in it does,
54
+ # under `prefix`.
47
55
  #
48
56
  # @param node [Hash] an aggregate or entity of the IR
49
57
  # @param prefix [String] the verb path down to `node`
50
- # @param table [Hash] filled in place
58
+ # @param tables [Hash] filled in place
51
59
  # @return [void]
52
- def collect_defaults(node, prefix, table)
60
+ def collect(node, prefix, tables)
53
61
  Array(node["commands"]).each do |command|
62
+ verb = "#{prefix}.#{command['name']}"
54
63
  held = Array(command["attributes"]).reject { |attribute| attribute["default"].nil? }
55
64
  .to_h { |attribute| [attribute["name"], attribute["default"]] }
56
- table["#{prefix}.#{command['name']}"] = held unless held.empty?
65
+ tables["defaults"][verb] = held unless held.empty?
66
+ tables["needs"][verb] = needs_of(command) unless Array(command["needs"]).empty?
67
+ end
68
+ Array(node["queries"]).each do |query|
69
+ tables["query_needs"]["#{prefix}.#{query['name']}"] = needs_of(query) unless Array(query["needs"]).empty?
70
+ end
71
+ Array(node["entities"]).each { |entity| collect(entity, "#{prefix}.#{entity['name']}", tables) }
72
+ end
73
+
74
+ # @param declaration [Hash] a command or query of the IR that declares `needs`
75
+ # @return [Array<Hash>] each needed fact with the type of the argument of the same name
76
+ def needs_of(declaration)
77
+ Array(declaration["needs"]).map do |need|
78
+ argument = Array(declaration["attributes"]).find { |attribute| attribute["name"] == need["fact"] }
79
+ { "fact" => need["fact"], "type" => argument ? argument["type"].to_s : "" }
57
80
  end
58
- Array(node["entities"]).each { |entity| collect_defaults(entity, "#{prefix}.#{entity['name']}", table) }
59
81
  end
60
82
 
61
83
  # @param domain_path [String] the domain's directory
@@ -3,7 +3,6 @@
3
3
  require "fileutils"
4
4
  require "json"
5
5
  require "tmpdir"
6
- require_relative "../rust_literal"
7
6
  require_relative "../write_if_changed"
8
7
 
9
8
  module Hecks
@@ -78,7 +77,6 @@ module Hecks
78
77
  def generate_meta(stage, tmp)
79
78
  ir_path = write_ir(tmp, "meta", @meta)
80
79
  run("full", stage, "meta", META_LABEL, ir_path)
81
- write_sidecars(File.join(stage, "meta"), @meta, META_LABEL)
82
80
  end
83
81
 
84
82
  def generate_target(stage, tmp)
@@ -87,9 +85,6 @@ module Hecks
87
85
  args.push(chapter.mod_name, chapter.label, write_ir(tmp, chapter.mod_name, chapter.ir))
88
86
  end
89
87
  run("full", *args)
90
- [@target, *@chapters].each do |chapter|
91
- write_sidecars(File.join(stage, chapter.mod_name), chapter.ir, chapter.label)
92
- end
93
88
  end
94
89
 
95
90
  def write_ir(tmp, name, tree)
@@ -98,19 +93,6 @@ module Hecks
98
93
  path
99
94
  end
100
95
 
101
- # `ir.json` is what `rust/host` reads at runtime, and `metadata.rs` embeds the same text;
102
- # the codegen binary writes neither.
103
- def write_sidecars(mod_dir, tree, label)
104
- text = JSON.pretty_generate(tree)
105
- File.write(File.join(mod_dir, "ir.json"), text)
106
- File.write(File.join(mod_dir, "metadata.rs"), <<~RUST)
107
- // GENERATED by hecks project_rust — #{label}'s own canonical IR,
108
- // embedded for runtime self-description. Not read by any dispatch
109
- // function in this module — introspection only.
110
- pub const IR_JSON: &str = #{RustLiteral.string(text)};
111
- RUST
112
- end
113
-
114
96
  def run(*args)
115
97
  return if system(self.class.binary(@rust_dir), *args, out: $stdout, err: $stderr)
116
98
 
@@ -5,7 +5,6 @@ require "json"
5
5
  require_relative "../../hecks"
6
6
  require_relative "../bluebook/meta_validator"
7
7
  require_relative "../ports/persistence/plugins/era"
8
- require_relative "append_optionals"
9
8
  require_relative "domain_name"
10
9
  require_relative "write_if_changed"
11
10
 
@@ -83,20 +82,14 @@ module Hecks
83
82
  vendored = @registry.hecksagons.values.flat_map(&:vendored_packages)
84
83
  chapters = (@registry.bluebooks.keys - [@domain_name]).map do |name|
85
84
  ir = Hecks::Projector::Exporter.call(@registry).fetch(name)
86
- CodegenRun::Chapter.new(name.downcase, "#{@domain} (#{attachment(name, vendored)})", prepared_ir(ir))
85
+ CodegenRun::Chapter.new(name.downcase, "#{@domain} (#{attachment(name, vendored)})", json_shaped(ir))
87
86
  end
88
87
  CodegenRun.new(
89
- rust_dir: @rust_dir, out_root: @out_root, meta: prepared_ir(meta), chapters: chapters,
90
- target: CodegenRun::Chapter.new(@mod_name, @domain, prepared_ir(@ir, shaped: false))
88
+ rust_dir: @rust_dir, out_root: @out_root, meta: json_shaped(meta), chapters: chapters,
89
+ target: CodegenRun::Chapter.new(@mod_name, @domain, @ir)
91
90
  ).call
92
91
  end
93
92
 
94
- # The IR as the generator reads it: string-keyed through JSON, with the append-optional
95
- # fields marked, since `ir.json` records them.
96
- def prepared_ir(tree, shaped: true)
97
- AppendOptionals.mark(shaped ? json_shaped(tree) : tree)
98
- end
99
-
100
93
  # Round-trips a value through JSON so generators see the string-keyed shape a real `ir.json`
101
94
  # carries, never live Ruby symbols.
102
95
  def json_shaped(payload) = JSON.parse(JSON.generate(payload), symbolize_names: true)
@@ -31,6 +31,7 @@ module Hecks
31
31
  TOOLS = {
32
32
  "project_rust" => ["rust_build/project_rust", :ProjectRust],
33
33
  "project_wasm" => ["rust_build/wasm", :Wasm],
34
+ "project_host" => ["rust_build/host", :Host],
34
35
  "project_wasm_browser" => ["rust_build/wasm_browser", :WasmBrowser],
35
36
  "rust_conformance" => ["rust_build/conformance", :Conformance],
36
37
  "rust_conformance_fuzz" => ["rust_build/conformance_fuzz", :ConformanceFuzz],
@@ -101,6 +101,10 @@ Hecks.bluebook "Site" do
101
101
  attribute :template, Location, optional: true
102
102
  attribute :extension, Extension, optional: true
103
103
  attribute :check, Switch, optional: true
104
+ attribute :live, Location, optional: true
105
+ attribute :distribution, DistributionId, optional: true
106
+ attribute :expect_new, PathList, optional: true
107
+ attribute :refs, PolicyRefs, optional: true
104
108
  attribute :output, Report, optional: true
105
109
  attribute :refusal, Report, optional: true
106
110
  identified_by :run
@@ -134,6 +138,21 @@ Hecks.bluebook "Site" do
134
138
  attribute :value, Boolean
135
139
  end
136
140
 
141
+ # A CloudFront distribution's id, whose configuration `check_live` reads.
142
+ value_object "DistributionId" do
143
+ attribute :value, String, pattern: '^[A-Z0-9]+$'
144
+ end
145
+
146
+ # Path patterns, comma separated: the behaviours a change is about to add to the live distribution.
147
+ value_object "PathList" do
148
+ attribute :value, String, pattern: '[^\t\n\r]'
149
+ end
150
+
151
+ # Policy references and the ids they stand for live, as `!Ref Name=id`, comma separated.
152
+ value_object "PolicyRefs" do
153
+ attribute :value, String, pattern: '[^\t\n\r]'
154
+ end
155
+
137
156
  value_object "Report" do
138
157
  attribute :value, String
139
158
  end
@@ -166,8 +185,36 @@ Hecks.bluebook "Site" do
166
185
  emits "SiteProjectionRequested"
167
186
  end
168
187
 
188
+ # `hecks site site_projection.check_live <project> live=<saved-config.json> | distribution=<id>
189
+ # [expect_new=<paths>] [refs=<!Ref Name=id,...>]`. Compares the project's generated CloudFront
190
+ # behaviours with a live distribution's, changing neither. `live` is a saved answer of
191
+ # `aws cloudfront get-distribution-config`; `distribution` fetches it with that one read-only call.
192
+ command "CheckLive" do
193
+ goal "Compare a project's generated CloudFront behaviours with a live distribution's"
194
+
195
+ attribute :domain, DomainPath
196
+ attribute :run, RunKey
197
+ attribute :live, Location, optional: true
198
+ attribute :distribution, DistributionId, optional: true
199
+ attribute :expect_new, PathList, optional: true
200
+ attribute :refs, PolicyRefs, optional: true
201
+
202
+ given("exactly one of a saved configuration and a distribution is named: pass live= or distribution=, not both") do
203
+ (live.set? && distribution.unset?) || (live.unset? && distribution.set?)
204
+ end
205
+
206
+ sets :domain
207
+ sets :run
208
+ sets :live
209
+ sets :distribution
210
+ sets :expect_new
211
+ sets :refs
212
+
213
+ emits "LiveCheckRequested"
214
+ end
215
+
169
216
  command "Complete" do
170
- goal "Record that routes.ts was written, or found current, and where"
217
+ goal "Record that routes.ts was written, or found current, or the live edge matched, and what it said"
171
218
 
172
219
  reference_to SiteProjection
173
220
  attribute :output, Report
@@ -207,6 +254,11 @@ Hecks.bluebook "Site" do
207
254
  trigger SiteProjection::SiteToolchain::Project, with: { run: :run }
208
255
  end
209
256
 
257
+ policy "CheckWhenRequested" do
258
+ on "SiteProjection.LiveCheckRequested"
259
+ trigger SiteProjection::SiteToolchain::Compare, with: { run: :run }
260
+ end
261
+
210
262
  policy "RecordTheProjection" do
211
263
  on "SiteProjection.SiteAnswered"
212
264
  trigger SiteProjection::Complete, with: { output: :output }
@@ -11,5 +11,11 @@ Hecks.hecksagon "Site" do
11
11
  answers "SiteAnswered"
12
12
  refuses "SiteRefused"
13
13
  end
14
+
15
+ asks "Compare", to: SiteProjection do
16
+ attribute :run, RunKey
17
+ answers "SiteAnswered"
18
+ refuses "SiteRefused"
19
+ end
14
20
  end
15
21
  end