hecks 3.2.0 → 3.2.1

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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +3 -3
  3. data/lib/hecks/cli/check_era.rb +3 -39
  4. data/lib/hecks/doors/mcp_door.rb +16 -2
  5. data/lib/hecks/doors/mcp_door_scope.rb +43 -4
  6. data/lib/hecks/doors/mcp_guide_cache.rb +75 -0
  7. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +17 -1
  8. data/lib/hecks/hecks/adapters/codebase/test_runner.rb +10 -2
  9. data/lib/hecks/hecks/adapters/rust_workspace.rb +1 -1
  10. data/lib/hecks/hecks/adapters/site_toolchain.rb +21 -11
  11. data/lib/hecks/language/bluebook/vocabulary.bluebook +7 -7
  12. data/lib/hecks/language/hecksagon/hecksagon.bluebook +1 -1
  13. data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +3 -3
  14. data/lib/hecks/projections/deploy/box/settings.rb +23 -2
  15. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +2 -0
  16. data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +17 -0
  17. data/lib/hecks/projections/deploy/box.rb +13 -2
  18. data/lib/hecks/projections/site/edge/behaviours.rb +5 -3
  19. data/lib/hecks/projections/site/edge/checks.rb +31 -5
  20. data/lib/hecks/projections/site/edge.rb +25 -11
  21. data/lib/hecks/projections/site/routes_ts.rb +50 -27
  22. data/lib/hecks/projections/site/site_cdn.rb +7 -4
  23. data/lib/hecks/projections/site/table/checks.rb +53 -7
  24. data/lib/hecks/projections/site/table/link_builder.rb +64 -0
  25. data/lib/hecks/projections/site/table/row_builder.rb +21 -6
  26. data/lib/hecks/projections/site/table.rb +16 -5
  27. data/lib/hecks/rust_build/kernel_input.rb +18 -6
  28. data/lib/hecks/rust_build/project_rust.rb +6 -132
  29. data/lib/hecks/site/bluebook/site.bluebook +27 -13
  30. data/lib/hecks/storehouse.rb +35 -1
  31. data/lib/hecks/tools/comment_style.rb +4 -2
  32. data/lib/hecks/tools/rust_comment_style.rb +1 -1
  33. data/lib/hecks/tools/site_routes.rb +69 -28
  34. data/lib/hecks/version.rb +1 -1
  35. data/lib/hecks/vocabulary.rb +1 -1
  36. data/lib/hecks.rb +1 -0
  37. data/rust/build/src/lineage_pass.rs +1 -1
  38. data/rust/build/src/pipeline.rs +1 -1
  39. data/rust/codegen/src/attr.rs +1 -1
  40. data/rust/codegen/src/bridging.rs +1 -1
  41. data/rust/codegen/src/commands.rs +1 -1
  42. data/rust/codegen/src/constraints.rs +1 -1
  43. data/rust/codegen/src/dependency_planning.rs +1 -1
  44. data/rust/codegen/src/domain_generator.rs +2 -2
  45. data/rust/codegen/src/exemplar.rs +1 -1
  46. data/rust/codegen/src/expr_emitter.rs +1 -1
  47. data/rust/codegen/src/fielded.rs +1 -1
  48. data/rust/codegen/src/json_codec.rs +1 -1
  49. data/rust/codegen/src/main.rs +3 -4
  50. data/rust/codegen/src/mutations.rs +1 -1
  51. data/rust/codegen/src/naming.rs +1 -1
  52. data/rust/codegen/src/ports.rs +1 -1
  53. data/rust/codegen/src/queries.rs +3 -3
  54. data/rust/codegen/src/reactions.rs +1 -1
  55. data/rust/codegen/src/read_models.rs +2 -2
  56. data/rust/codegen/src/reference_specs.rs +1 -1
  57. data/rust/codegen/src/registry.rs +4 -4
  58. data/rust/codegen/src/shared.rs +1 -1
  59. data/rust/codegen/src/skip_reason.rs +1 -1
  60. data/rust/codegen/src/types.rs +4 -4
  61. data/rust/host/HECKS_RELEASE +1 -1
  62. data/rust/host/src/needs.rs +94 -27
  63. data/rust/src/exemplar/commands.rs +1 -1
  64. data/rust/src/exemplar/constraints.rs +1 -1
  65. data/rust/src/exemplar/fielded.rs +1 -1
  66. data/rust/src/exemplar/json.rs +1 -1
  67. data/rust/src/exemplar/mod.rs +1 -1
  68. data/rust/src/exemplar/mutations.rs +1 -1
  69. data/rust/src/exemplar/queries.rs +1 -1
  70. data/rust/src/exemplar/reactions.rs +1 -1
  71. data/rust/src/exemplar/read_models.rs +1 -1
  72. data/rust/src/exemplar/registry.rs +1 -1
  73. data/rust/src/exemplar/types.rs +1 -1
  74. data/rust/src/kernel/cli.rs +2 -2
  75. data/rust/src/kernel/read_model.rs +1 -1
  76. data/rust/src/lib.rs +1 -1
  77. metadata +3 -23
  78. data/rust/project/bridging.rb +0 -257
  79. data/rust/project/commands.rb +0 -877
  80. data/rust/project/constraints.rb +0 -86
  81. data/rust/project/dependency_planning.rb +0 -178
  82. data/rust/project/domain_generator.rb +0 -983
  83. data/rust/project/exemplar.rb +0 -148
  84. data/rust/project/expr_emitter.rb +0 -72
  85. data/rust/project/fielded.rb +0 -141
  86. data/rust/project/json_codec.rb +0 -641
  87. data/rust/project/mutations.rb +0 -695
  88. data/rust/project/naming.rb +0 -223
  89. data/rust/project/ports.rb +0 -65
  90. data/rust/project/queries.rb +0 -493
  91. data/rust/project/reactions.rb +0 -317
  92. data/rust/project/read_models.rb +0 -690
  93. data/rust/project/reference_specs.rb +0 -27
  94. data/rust/project/registry.rb +0 -396
  95. data/rust/project/skip_reason.rb +0 -29
  96. data/rust/project/types.rb +0 -225
  97. data/rust/project/write_if_changed.rb +0 -7
  98. data/rust/project.rb +0 -30
  99. data/rust/project_rust_pipeline.rb +0 -391
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c79ef6cb53e23b9faa0f452f116af208c659512f00716e47c7f3cdaad3d51e86
4
- data.tar.gz: b693ea21f847607ca2964759f9e0cec9dc7c4f8e1ea341b6636e99f8b4b1d294
3
+ metadata.gz: 9b963870a34a1483bc0156042a2967a72f8fc1b8e907ba5d8cac5a5382d4999d
4
+ data.tar.gz: 26697af02778cda282a44c1ca099e36a9866bf2d204ab5abb23df8ac2a86283a
5
5
  SHA512:
6
- metadata.gz: 38d1467d95484bcf093d6671982bf5e4edc4fc1be8907d849a88df9b85ab2b9810f88ce9533a9d1d125187fd47c26191fbe3e104e90307ee6fb19b3cffa37546
7
- data.tar.gz: 329631cf0d425b281c4a4770318d2956ae95ca7d2e0b12e2e0054bd2f0d72a3310a92d1ce2fa125d5a056c76026c27472230a0710335790267db173ada8e48db
6
+ metadata.gz: a81b0ffb4b0e04024518169e3c6dd51a3f55b5da39659a852c2a0c9528453fe0eb9facf07d7b18bd35d7924c5a08331b2f1ad82a7e5c4c43c61fae55490d46ed
7
+ data.tar.gz: f6780cb0c58069a5049081c7a319424787c9fea1fd94a5085153d7b99533c0e1974c23dfeb3515b66f3b2d8d3c7ca83a08dcd5f024df02da2b2fc9609c3ba6b7
@@ -57,9 +57,9 @@ module Hecks
57
57
  end
58
58
  end
59
59
 
60
- # The release that drops the deprecated spellings of `attaches`; kept in step with
61
- # `Hecks::Doors::REMOVAL`, which this file cannot require.
62
- REMOVAL = "3.2.0".freeze
60
+ # The release that drops the deprecated spellings of `attaches`; independent of
61
+ # `Hecks::Doors::REMOVAL`, which covers the older `Facade` aliases.
62
+ REMOVAL = "3.3.0".freeze
63
63
 
64
64
  attr_reader :binds, :subscriptions, :attachments
65
65
 
@@ -2,11 +2,9 @@ require_relative "../ports/persistence/plugins/era/expected_era"
2
2
 
3
3
  module Hecks
4
4
  module CLI
5
- # The command behind `hecks check_era` and Custodian's `Host.CheckEra`: asks a running host
6
- # which era it reports and compares it with an allow-list file. Read-only: one GET, no writes.
5
+ # The check behind Custodian's `Host.CheckEra`: asks a running host which era it reports and
6
+ # compares it with an allow-list file. Read-only: one GET, no writes.
7
7
  module CheckEra
8
- USAGE = "usage: hecks check_era <url> <expected-era-file> [--timeout=<seconds>]".freeze
9
-
10
8
  # What a check found.
11
9
  #
12
10
  # @!attribute [r] verdict
@@ -14,7 +12,7 @@ module Hecks
14
12
  # @!attribute [r] version
15
13
  # @return [String] the gem version the host reports, empty when it reports none
16
14
  # @!attribute [r] line
17
- # @return [String] the sentence `hecks check_era` prints for this finding
15
+ # @return [String] the sentence the check reports for this finding
18
16
  Finding = Struct.new(:verdict, :version, :line)
19
17
 
20
18
  module_function
@@ -36,35 +34,6 @@ module Hecks
36
34
  Finding.new(verdict, document.fetch("version"), sentence(verdict, file))
37
35
  end
38
36
 
39
- # Runs `hecks check_era`: prints the finding and answers the exit status.
40
- #
41
- # @param argv [Array<String>] the url, the allow-list file and an optional `--timeout=N`
42
- # @param out [IO] where a finding goes
43
- # @param err [IO] where a refusal goes
44
- # @return [Integer] 0 when the era is expected or unlisted, 1 on a mismatch, 2 on bad
45
- # usage or an unreadable file, 3 when the host cannot be reached or answers no era
46
- def run(argv, out: $stdout, err: $stderr)
47
- timeout = 10
48
- args = argv.reject do |arg|
49
- next false unless arg.start_with?("--timeout=")
50
-
51
- timeout = arg.delete_prefix("--timeout=").to_f
52
- true
53
- end
54
- url, file = args
55
- return usage(err) unless url && file && args.size == 2 && timeout.positive?
56
-
57
- finding = assess(url, file, timeout: timeout)
58
- (finding.verdict.ok? ? out : err).puts(finding.line)
59
- finding.verdict.ok? ? 0 : 1
60
- rescue Errno::ENOENT, Errno::EACCES => e
61
- err.puts("cannot read #{file}: #{e.message}")
62
- 2
63
- rescue Runtime::EraCheck::ExpectedEra::Unreachable, Runtime::EraCheck::ExpectedEra::BadResponse => e
64
- err.puts("era check failed: #{e.message}")
65
- 3
66
- end
67
-
68
37
  # @param verdict [Runtime::EraCheck::ExpectedEra::Verdict] the comparison
69
38
  # @param file [String] the allow-list file, named in the sentence
70
39
  # @return [String] the one line that says what the comparison found
@@ -78,11 +47,6 @@ module Hecks
78
47
  "host reports era #{verdict.era}, which is not in #{file} (expected: #{verdict.allowed.join(', ')})"
79
48
  end
80
49
  end
81
-
82
- def usage(err)
83
- err.puts(USAGE)
84
- 2
85
- end
86
50
  end
87
51
  end
88
52
  end
@@ -343,7 +343,20 @@ module Hecks
343
343
  def answer_tool(name, args)
344
344
  return scope.refusal(name) unless scope.permits_tool?(name)
345
345
 
346
- call_tool(name, args)
346
+ call_tool(name, scope.with_default_domain(args))
347
+ end
348
+
349
+ # One line per allowed command: what it takes and the role it declares, read from the booted
350
+ # domain the door serves. Nil when the door serves no single domain or it will not boot, so
351
+ # `tools/list` still answers, with the commands as a bare list.
352
+ def command_guide
353
+ return unless scope.commands_mode? && scope.default_domain
354
+
355
+ McpGuideCache.remember(scope.default_domain, scope.allowed_commands) do
356
+ Storehouse.command_guide(boot(scope.default_domain), scope.allowed_commands)
357
+ end
358
+ rescue StandardError
359
+ nil
347
360
  end
348
361
 
349
362
  def tool_result(payload)
@@ -374,7 +387,8 @@ module Hecks
374
387
  nil # a notification — no id, no response
375
388
  when "tools/list"
376
389
  served = TOOLS.select { |tool| scope.permits_tool?(tool[:name]) }
377
- send_response(id, { tools: served.map { |tool| scope.present(tool) } })
390
+ guide = command_guide
391
+ send_response(id, { tools: served.map { |tool| scope.present(tool, guide) } })
378
392
  when "tools/call"
379
393
  send_response(id, tool_result(answer_tool(params["name"], params["arguments"] || {})))
380
394
  when "ping"
@@ -201,18 +201,40 @@ module Hecks
201
201
  end
202
202
  end
203
203
 
204
- # The tool as `tools/list` shows it: on a commands door, `dispatch` names the commands it
205
- # serves, so a caller sees them as an enum instead of finding out by being refused.
204
+ # The one domain a restricted door serves, when it serves exactly one: a call may leave
205
+ # `domain:` out and get it. Nil for an unrestricted door and for one that names several.
206
+ #
207
+ # @return [String, nil] the domain path as `HECKS_DOOR_DOMAINS` named it, resolved
208
+ def default_domain
209
+ @allowed_domains.first if restricted? && @allowed_domains.size == 1
210
+ end
211
+
212
+ # The arguments with the default domain filled in when the caller left it out.
213
+ #
214
+ # @param args [Hash] a tool call's arguments
215
+ # @return [Hash] `args`, with `"domain"` set when the door has a default and none was given
216
+ def with_default_domain(args)
217
+ return args unless default_domain && args["domain"].to_s.strip.empty?
218
+
219
+ args.merge("domain" => default_domain)
220
+ end
221
+
222
+ # The tool as `tools/list` shows it. A door that serves one domain makes `domain:` optional.
223
+ # On a commands door, `dispatch` also names the commands it serves as an enum, so a caller
224
+ # sees them as typed choices instead of finding out by being refused, and says what each
225
+ # takes and which role it declares.
206
226
  #
207
227
  # @param tool [Hash] a tool definition from `McpDoor::TOOLS`
228
+ # @param guide [Array<String>, nil] one line per allowed command (`Storehouse.command_guide`)
208
229
  # @return [Hash] the definition, narrowed for this door
209
- def present(tool)
230
+ def present(tool, guide = nil)
231
+ tool = domain_optional(tool) if default_domain
210
232
  return tool unless commands_mode? && tool[:name] == "dispatch"
211
233
 
212
234
  properties = tool[:inputSchema][:properties]
213
235
  narrowed = properties.merge(command: with_enum(properties[:command]),
214
236
  steps: properties[:steps].merge(items: step_items(properties[:steps][:items])))
215
- tool.merge(description: "#{tool[:description]} This door dispatches only: #{allowed_commands.join(', ')}.",
237
+ tool.merge(description: dispatch_description(tool[:description], guide),
216
238
  inputSchema: tool[:inputSchema].merge(properties: narrowed))
217
239
  end
218
240
 
@@ -287,6 +309,23 @@ module Hecks
287
309
  File.join(File.realpath(current), *missing)
288
310
  end
289
311
 
312
+ def domain_optional(tool)
313
+ schema = tool[:inputSchema]
314
+ return tool unless schema[:properties].key?(:domain)
315
+
316
+ note = "Optional: this door serves one domain, #{default_domain}, and uses it when you leave this out."
317
+ properties = schema[:properties].merge(domain: schema[:properties][:domain].merge(description: note))
318
+ tool.merge(inputSchema: schema.merge(properties: properties, required: schema[:required] - ["domain"]))
319
+ end
320
+
321
+ def dispatch_description(base, guide)
322
+ intro = "#{base} This door dispatches only the commands below, each with the role it declares: pass " \
323
+ "that role as `role`. A command that takes `run` takes a key you choose, and the call answers " \
324
+ "the record as it stands once its reactions have run; an argument marked * is required."
325
+ entries = Array(guide).empty? ? allowed_commands : guide
326
+ ([intro] + entries.map { |entry| "- #{entry}" }).join("\n")
327
+ end
328
+
290
329
  def with_enum(property)
291
330
  property.merge(enum: allowed_commands)
292
331
  end
@@ -0,0 +1,75 @@
1
+ require "digest"
2
+ require "fileutils"
3
+ require "json"
4
+ require_relative "../cache_dir"
5
+ require_relative "../storehouse"
6
+ require_relative "../bluebook/meta_validator/verdict_cache"
7
+
8
+ module Hecks
9
+ module Doors
10
+ # Remembers the command guide a commands door builds for `tools/list`, so a door that starts
11
+ # again does not boot its domain to describe its commands. A client waits only a moment for a
12
+ # server's tools, and a boot at that point costs more than the wait.
13
+ #
14
+ # An entry is keyed by the domain directory's files, a digest of the hecks code, and the
15
+ # allowed commands, so a change to any of them builds a new guide. The file is JSON, read only
16
+ # from a directory and a file that only this user can write (the verdict cache's own check),
17
+ # and only when it is a list of strings: a guide is text an agent reads as instructions.
18
+ module McpGuideCache
19
+ module_function
20
+
21
+ # The guide for a domain and its allowed commands, from the cache or else from the block,
22
+ # which is then remembered. A cache that cannot be written is not an error: the next start
23
+ # builds the guide again.
24
+ #
25
+ # @param domain [String] the domain directory the guide describes
26
+ # @param commands [Array<String>] the allowed command names
27
+ # @yieldreturn [Array<String>, nil] the guide, built when none is remembered
28
+ # @return [Array<String>, nil] the guide
29
+ def remember(domain, commands)
30
+ file = File.join(dir, "#{key(domain, commands)}.json")
31
+ cached = read(file)
32
+ return cached if cached
33
+
34
+ guide = yield
35
+ write(file, guide) if guide.is_a?(Array) && guide.any?
36
+ guide
37
+ end
38
+
39
+ # @return [String] the directory entries live in
40
+ def dir = Hecks::CacheDir.path("mcp_door_guides")
41
+
42
+ # @param domain [String] the domain directory
43
+ # @param commands [Array<String>] the allowed command names
44
+ # @return [String] a hex SHA-256 over the domain's files, the hecks code and the commands
45
+ def key(domain, commands)
46
+ parts = [Storehouse.fingerprint(domain), Bluebook::MetaValidator::VerdictCache.code_digest,
47
+ commands.sort.join(",")]
48
+ Digest::SHA256.hexdigest(parts.join("\0"))
49
+ end
50
+
51
+ # @param file [String] an entry's path
52
+ # @return [Array<String>, nil] the guide, or nil when absent, untrusted or malformed
53
+ def read(file)
54
+ return unless File.file?(file) && Bluebook::MetaValidator::VerdictCache.trusted?(file)
55
+
56
+ guide = JSON.parse(File.read(file))
57
+ guide if guide.is_a?(Array) && guide.all?(String)
58
+ rescue StandardError
59
+ nil
60
+ end
61
+
62
+ # @param file [String] an entry's path
63
+ # @param guide [Array<String>] the guide to remember
64
+ # @return [void]
65
+ def write(file, guide)
66
+ FileUtils.mkdir_p(dir, mode: 0o700)
67
+ temp = "#{file}.#{Process.pid}.tmp"
68
+ File.write(temp, JSON.generate(guide), perm: 0o600)
69
+ File.rename(temp, file)
70
+ rescue StandardError
71
+ FileUtils.rm_f(temp) if temp
72
+ end
73
+ end
74
+ end
75
+ end
@@ -118,8 +118,9 @@ module Hecks
118
118
 
119
119
  owner = owner_for_verb(bluebooks, entry[:verb]) || aggregate
120
120
 
121
+ args = with_declared_defaults(command, entry[:args])
121
122
  command.mutations.select { |m| RECOMPUTABLE_MUTATION_OPS.include?(m.op) }.filter_map do |mutation|
122
- expected = recompute_mutation(mutation, entry[:before][mutation.target], entry[:args], entry[:before],
123
+ expected = recompute_mutation(mutation, entry[:before][mutation.target], args, entry[:before],
123
124
  aggregate, command, owner)
124
125
  next if expected == :unrecomputable
125
126
 
@@ -134,6 +135,21 @@ module Hecks
134
135
  offenders.empty? || offenders.join("; ")
135
136
  end
136
137
 
138
+ # The arguments the command actually ran with: an argument the caller left out takes the
139
+ # default its attribute declares, as dispatch fills it, so the recomputation starts from
140
+ # the same facts.
141
+ #
142
+ # @param command [Object] the command the step dispatched
143
+ # @param args [Hash] the arguments the step offered
144
+ # @return [Hash] `args`, with each omitted defaulted argument filled
145
+ def with_declared_defaults(command, args)
146
+ command.attributes.each_with_object(args.dup) do |attribute, held|
147
+ next if attribute.default.nil? || held.key?(attribute.name.to_sym) || held.key?(attribute.name.to_s)
148
+
149
+ held[attribute.name.to_sym] = attribute.default
150
+ end
151
+ end
152
+
137
153
  # Root aggregate for `verb`, derived from the verb alone because hand-built
138
154
  # entries may lack `:domain`/`:aggregate`. Value-object types resolve against
139
155
  # the root's namespace only.
@@ -38,7 +38,9 @@ module Hecks
38
38
  status = Dir.chdir(@tree.root) { runner.call(["--example", example, path], out, out) }
39
39
  raise ConsoleCapture::Failure, out.string.strip unless status.zero?
40
40
 
41
- out.string.strip
41
+ out.string.strip.tap do |report|
42
+ raise ConsoleCapture::Failure, "the test runner printed nothing for #{file}" if report.empty?
43
+ end
42
44
  end
43
45
 
44
46
  private
@@ -47,7 +49,13 @@ module Hecks
47
49
  return self.class.runner if self.class.runner
48
50
 
49
51
  require "rspec/core"
50
- RSpec::Core::Runner.method(:run)
52
+ # A process that runs one example after another, as a resident door does, has to reset
53
+ # RSpec between runs: its reporter keeps the first run's output stream, so a later run
54
+ # would write into that and answer an empty report.
55
+ lambda do |args, err, out|
56
+ RSpec.reset
57
+ RSpec::Core::Runner.run(args, err, out)
58
+ end
51
59
  end
52
60
  end
53
61
  end
@@ -22,7 +22,7 @@ module Hecks
22
22
  CHECKOUT_MARKER = "hecks.gemspec"
23
23
 
24
24
  # What a copy holds: the workspace manifest, the kernel and every crate a domain build uses.
25
- PACKAGED = %w[Cargo.toml Cargo.lock project.rb project_rust_pipeline.rb project src codegen parser
25
+ PACKAGED = %w[Cargo.toml Cargo.lock src codegen parser
26
26
  host build web lsp].freeze
27
27
 
28
28
  # Left out of a copy: build output anywhere, the corpus tests, and the corpus domains.
@@ -9,13 +9,16 @@ module Hecks
9
9
  # The `SiteToolchain` port's adapter: projects a site's route table into its `routes.ts`, or
10
10
  # checks the file on disk is current.
11
11
  #
12
- # The projection runs the `project_site` tool in a child of the checkout, with its printing and
13
- # exit status captured: a tool that ends non-zero is a refusal whose reason is what it printed.
14
- # Outside a hecks checkout the ask is refused with "needs a hecks checkout".
12
+ # The projection runs the `project_site` tool in this process, with its printing and exit status
13
+ # captured: a tool that ends non-zero is a refusal whose reason is what it printed. The tool
14
+ # ships in the gem and reads only the project it is given, so it needs no hecks checkout.
15
15
  class SiteToolchain
16
16
  # The `Hecks::Tools` tool the ask runs, by ask.
17
17
  SCRIPTS = { project: "project_site" }.freeze
18
18
 
19
+ # The tool's flag for each `SiteProjection` field that takes a value.
20
+ FLAGS = { "out" => :out, "template" => :template, "extension" => :extension }.freeze
21
+
19
22
  # Accepts the arguments every driven adapter is built with and keeps none of them.
20
23
  #
21
24
  # @param aggregate [Object, nil] unused
@@ -25,17 +28,17 @@ module Hecks
25
28
 
26
29
  # Writes a project's `routes.ts` from its declared route table, or with `check` only compares.
27
30
  #
28
- # @param held [Hash] the `SiteProjection` record: `domain`, and `out` and `check` when set
31
+ # @param held [Hash] the `SiteProjection` record: `domain`, and `out`, `template`, `extension`
32
+ # and `check` when set
29
33
  # @return [Hash{Symbol => Hash}] `output:` one line per file written or found current
30
- # @raise [ConsoleCapture::Failure] when the tree is not a checkout, the route table is
31
- # refused (an unknown cache class, a duplicate path), or under `check` a file is out of date
34
+ # @raise [ConsoleCapture::Failure] when the route table is refused (an unknown cache class,
35
+ # a duplicate path), a path or the extension is refused, or under `check` a file is out
36
+ # of date
32
37
  def project(**held)
33
- flags = []
34
- flags << "--out=#{plain(held[:out])}" unless plain(held[:out]).nil?
38
+ flags = FLAGS.filter_map { |flag, key| "--#{flag}=#{located(key, held[key])}" unless plain(held[key]).nil? }
35
39
  flags << "--check" if plain(held[:check]) == true
36
- tree = Codebase::Tree.new
37
- tree.require_checkout!
38
- result = Codebase::RubyChild.new(tree).capture(SCRIPTS.fetch(:project), *flags, plain(held[:domain]))
40
+ result = Codebase::RubyChild.new(Codebase::Tree.new).capture(SCRIPTS.fetch(:project), *flags,
41
+ located(:domain, held[:domain]))
39
42
  raise ConsoleCapture::Failure, message_of(result) unless result.ok?
40
43
 
41
44
  { output: { value: result.out } }
@@ -48,6 +51,13 @@ module Hecks
48
51
  text.empty? ? "the tool ended with status #{result.status.exitstatus}" : text
49
52
  end
50
53
 
54
+ # The tool runs from the tool's own root, so a path the caller gave relative to where they
55
+ # stand is made absolute first; the extension is not a path.
56
+ def located(key, argument)
57
+ value = plain(argument)
58
+ value.nil? || key == :extension ? value : File.expand_path(value)
59
+ end
60
+
51
61
  def plain(argument) = argument.is_a?(Hash) ? argument[:value] : argument
52
62
  end
53
63
  end
@@ -625,16 +625,16 @@ Hecks.bluebook "Bluebook" do
625
625
  pattern: '^lib/hecks/runtime/', push: "skip",
626
626
  label: "does this change touch lib/hecks/runtime/**?"
627
627
  member name: "postgres_io_relevant_changed", workflow: "ci-postgres-io-parallel.yml", mode: "skips_unless",
628
- pattern: '^(docs/|editors/|release/|deploy/|\.claude/|\.githooks/|rust/(parser|codegen|host|build|lsp|web|tests|project)/|rust/project\.rb$|rust/project_rust_pipeline\.rb$|\.rubocop\.yml$|\.rubocop_todo\.yml$|\.mcp\.json$|\.rspec-local\.example$|README\.md$|CHANGELOG\.md$|CONTRIBUTING\.md$|SECURITY\.md$|LICENSE$|\.gitignore$)',
628
+ pattern: '^(docs/|editors/|release/|deploy/|\.claude/|\.githooks/|rust/(parser|codegen|host|build|lsp|web|tests)/|\.rubocop\.yml$|\.rubocop_todo\.yml$|\.mcp\.json$|\.rspec-local\.example$|README\.md$|CHANGELOG\.md$|CONTRIBUTING\.md$|SECURITY\.md$|LICENSE$|\.gitignore$)',
629
629
  push: "before_sha",
630
630
  label: "does this change touch anything rspec_postgres_io_parallel covers?"
631
631
  end
632
632
 
633
633
  # Rust keywords (strict + reserved) a generated identifier must not
634
- # collide with. RustProjection::Projector::RUST_KEYWORDS reads this;
635
- # hecks project_reserved_names projects it into
636
- # rust/codegen/src/reserved_names.rs, so Ruby and Rust escape or refuse
637
- # the same words from one declaration instead of two hand-typed copies.
634
+ # collide with. ModelCheck reads this; hecks project_reserved_names
635
+ # projects it into rust/codegen/src/reserved_names.rs, so Ruby and Rust
636
+ # escape or refuse the same words from one declaration instead of two
637
+ # hand-typed copies.
638
638
  value_object "RustReservedWord" do
639
639
  attribute :word, String,
640
640
  one_of: ["as", "break", "const", "continue", "crate", "dyn", "else", "enum", "extern", "false",
@@ -648,8 +648,8 @@ Hecks.bluebook "Bluebook" do
648
648
  # as a Cargo `[features]` key: every key rust/Cargo.toml already uses
649
649
  # outside `[features]` (`[package]`, `[lib]`, `[[bin]]`) plus `default`,
650
650
  # which Cargo reserves for the auto-enabled feature set. Read by
651
- # RustProjection::Projector::CARGO_RESERVED_DOMAIN_NAMES and projected by
652
- # hecks project_reserved_names, same as RustReservedWord above.
651
+ # Hecks::RustBuild::DomainName and projected by hecks
652
+ # project_reserved_names, same as RustReservedWord above.
653
653
  value_object "CargoReservedName" do
654
654
  attribute :name, String,
655
655
  one_of: ["name", "version", "edition", "path", "authors", "license", "description", "default",
@@ -83,7 +83,7 @@ Hecks.bluebook "Hecksagon" do
83
83
  member word: "hecksagon", context: "File", body: "keywords", inner: "Hecksagon", opens: "Hecksagon", fills: ""
84
84
  member word: "subscribe", context: "Hecksagon", body: "none", inner: "", opens: "", fills: "subscriptions"
85
85
  member word: "attaches", context: "Hecksagon", body: "none", inner: "", opens: "", fills: "attachments"
86
- # Deprecated spellings of `attaches`; they warn and are removed in 3.2.0.
86
+ # Deprecated spellings of `attaches`; they warn and are removed in 3.3.0.
87
87
  member word: "uses_framework", context: "Hecksagon", body: "none", inner: "", opens: "", fills: "attachments", status: "deprecated"
88
88
  member word: "uses_embryonaut_bluebook", context: "Hecksagon", body: "none", inner: "", opens: "", fills: "attachments", status: "deprecated"
89
89
  member word: "port", context: "Hecksagon", body: "keywords", inner: "DomainPort", opens: "DomainPort", fills: "", calls: "port_impl"
@@ -58,7 +58,7 @@ module Hecks
58
58
  # @return [String] the `era` the host's `/version` document carries
59
59
  # @raise [Unreachable] if the request fails or the status is not `200`
60
60
  # @raise [BadResponse] if the body is not JSON or its `era` is not a non-empty string
61
- def fetch_era(url, timeout: 10)
61
+ def fetch_era(url, timeout:)
62
62
  fetch_version(url, timeout: timeout).fetch("era")
63
63
  end
64
64
 
@@ -70,7 +70,7 @@ module Hecks
70
70
  # host reports none)
71
71
  # @raise [Unreachable] if the request fails or the status is not `200`
72
72
  # @raise [BadResponse] if the body is not JSON or its `era` is not a non-empty string
73
- def fetch_version(url, timeout: 10)
73
+ def fetch_version(url, timeout:)
74
74
  response = get(version_url(url), timeout)
75
75
  raise Unreachable, "#{version_url(url)} answered #{response.code}" unless response.code == "200"
76
76
 
@@ -105,7 +105,7 @@ module Hecks
105
105
  # @raise [Errno::ENOENT] if the allow-list file does not exist
106
106
  # @raise [Unreachable] see `fetch_era`
107
107
  # @raise [BadResponse] see `fetch_era`
108
- def check(url, file, timeout: 10)
108
+ def check(url, file, timeout:)
109
109
  allowed = parse(File.read(file))
110
110
  verdict(fetch_era(url, timeout: timeout), allowed)
111
111
  end
@@ -34,6 +34,8 @@ module Hecks
34
34
  "@sha256:226d1f059b75399fe19182893c7184591c07b97afc8dfcf44eeb80c9a77a530f".freeze
35
35
  TUNNEL_SHAPE = "tunnel: a hash needs `to` (the container it forwards to) and `token_secret` " \
36
36
  "(the secret holding the tunnel token)".freeze
37
+ ORIGIN_ENV_SHAPE = "origin_env: the container environment variable names that hold the origin secret " \
38
+ "need an origin_secret and a task_definition to compare against".freeze
37
39
  ORIGIN_PAIR = "origin_header and origin_secret go together: the header a CDN sends, " \
38
40
  "and the secret that holds its value".freeze
39
41
 
@@ -79,7 +81,7 @@ module Hecks
79
81
  :infra_name, :stack_prefix, :instance_type, :volume_gb, :swap_gb, :database_class,
80
82
  :storage_gb, :backup_days, :snapshots_keep, :database_name, :engine_version,
81
83
  :containers, :routes, :default_container, :origin_header, :origin_secret,
82
- :secret_prefixes, :writable_secrets, :tunnel, :tunnel_service, :proxy_image, :task_definition,
84
+ :secret_prefixes, :writable_secrets, :origin_env, :tunnel, :tunnel_service, :proxy_image, :task_definition,
83
85
  :migration, :s3_buckets, keyword_init: true
84
86
  ) do
85
87
  # @return [String] the CloudFormation stack that holds the database
@@ -110,6 +112,7 @@ module Hecks
110
112
  task_definition = read_task_definition(s[:task_definition], listed)
111
113
  containers = read_containers(listed, infra_name)
112
114
  header, secret = read_origin(s)
115
+ origin_env = read_origin_env(s, secret, task_definition)
113
116
  tunnel, tunnel_service = read_tunnel(s.fetch(:tunnel, false), containers)
114
117
 
115
118
  Plan.new(
@@ -118,7 +121,7 @@ module Hecks
118
121
  engine_version: check(:engine_version, s.fetch(:engine_version, "16").to_s, ENGINE),
119
122
  containers: containers, routes: read_routes(s.fetch(:routes, []), containers),
120
123
  default_container: read_default(s[:default_container], containers), origin_header: header,
121
- origin_secret: secret, secret_prefixes: read_prefixes(s, infra_name),
124
+ origin_secret: secret, origin_env: origin_env, secret_prefixes: read_prefixes(s, infra_name),
122
125
  writable_secrets: read_writable_secrets(s),
123
126
  tunnel: tunnel, tunnel_service: tunnel_service,
124
127
  proxy_image: check(:proxy_image, s.fetch(:proxy_image, PROXY_IMAGE), IMAGE),
@@ -263,6 +266,24 @@ module Hecks
263
266
  [check(:origin_header, header, HEADER), check(:origin_secret, secret, SECRET_NAME)]
264
267
  end
265
268
 
269
+ # The container environment variables that hold the origin secret in the task
270
+ # definition. Only Caddy reads the named secret; the containers keep the task
271
+ # definition's copy, so a copy that differs makes every request through the CDN
272
+ # fail. Naming them lets deploy refuse a mismatch.
273
+ #
274
+ # @param settings [Hash{Symbol => Object}] the world's `AwsBox` settings
275
+ # @param secret [String, nil] the declared origin secret
276
+ # @param task_definition [String, nil] the declared task definition family
277
+ # @return [Array<String>] the variable names, empty when none are declared
278
+ # @raise [ArgumentError] when named without an origin secret and a task definition
279
+ def read_origin_env(settings, secret, task_definition)
280
+ names = Array(settings.fetch(:origin_env, []))
281
+ return [] if names.empty?
282
+ raise ArgumentError, ORIGIN_ENV_SHAPE if secret.nil? || task_definition.nil?
283
+
284
+ names.map { |name| check(:origin_env, name, ENV_KEY) }.uniq
285
+ end
286
+
266
287
  # `tunnel true` only opens the outbound port; a hash also runs cloudflared as a service.
267
288
  #
268
289
  # @param value [Boolean, Hash{Symbol => Object}] the world's `tunnel` setting
@@ -55,6 +55,7 @@ ROLL=$(jq -n --arg compose "$(B64 "$WORK/compose.json")" --arg secrets "$(B64 "$
55
55
  --arg registry "$ACCOUNT.dkr.ecr.$REGION.amazonaws.com" --arg dir "$DIR" '
56
56
  {commands: ["cloud-init status --wait >/dev/null 2>&1 || true",
57
57
  "set -e", "mkdir -p \($dir)/caddy-extra && cd \($dir)", "umask 077",
58
+ "CADDY_BEFORE=$(cat Caddyfile 2>/dev/null | sha256sum)",
58
59
  "echo \($compose) | base64 -d > compose.json; echo \($secrets) | base64 -d > secrets.json",
59
60
  "echo \($caddy) | base64 -d > Caddyfile; echo \($fetch) | base64 -d > fetch-secrets.sh",
60
61
  "bash fetch-secrets.sh",
@@ -62,6 +63,7 @@ ROLL=$(jq -n --arg compose "$(B64 "$WORK/compose.json")" --arg secrets "$(B64 "$
62
63
  "aws ecr get-login-password --region '"$REGION"' | docker login --username AWS --password-stdin \($registry) >/dev/null",
63
64
  "docker compose -f compose.json pull --quiet",
64
65
  "docker compose -f compose.json up -d --remove-orphans",
66
+ "[ \"$(sha256sum < Caddyfile)\" = \"$CADDY_BEFORE\" ] || docker compose -f compose.json restart caddy",
65
67
  "for i in $(seq 1 40); do docker compose -f compose.json ps --format \"{{.Status}}\" | grep -qE \"^Up (Less than a second|[0-4] seconds?)|Restarting|Created|Exited\" || break; sleep 1; done; docker compose -f compose.json ps --format \"table {{.Service}}\\t{{.Status}}\""],
66
68
  executionTimeout: ["900"]}')
67
69
 
@@ -9,6 +9,10 @@
9
9
  # are replaced with the RDS stack's values where the task defines them, and every container runs on
10
10
  # the box's own network, so the proxy reaches each one on 127.0.0.1:<port>. Secrets (name + valueFrom)
11
11
  # are written to secrets.json for fetch-secrets.sh to resolve on the box.
12
+ #
13
+ # When the world names origin_env, the named origin secret is compared with those variables in the
14
+ # task definition and the render refuses on any difference (Caddy checks the CDN's header against
15
+ # the secret; the containers check it against their own copy). Values are never printed.
12
16
  set -euo pipefail
13
17
  HERE=$(cd "$(dirname "$0")" && pwd)
14
18
  DB_HOST=${1:?rds endpoint}
@@ -18,6 +22,19 @@ TD=${3:-@@FAMILY@@}
18
22
  DEF=$(aws ecs describe-task-definition --task-definition "$TD" --query 'taskDefinition.containerDefinitions' --output json)
19
23
  echo "rendering from task definition $TD" >&2
20
24
 
25
+ ORIGIN_ENV=$(jq -r '.origin.env // [] | .[]' "$HERE/services.json")
26
+ if [ -n "$ORIGIN_ENV" ]; then
27
+ ORIGIN_SECRET_ID=$(jq -r '.origin.secret' "$HERE/services.json")
28
+ ORIGIN_VALUE=$(aws secretsmanager get-secret-value --secret-id "$ORIGIN_SECRET_ID" --query SecretString --output text)
29
+ for NAME in $ORIGIN_ENV; do
30
+ HOLDERS=$(echo "$DEF" | jq -r --arg n "$NAME" '[.[] | select(any((.environment // [])[]; .name == $n)) | .name] | join(" ")')
31
+ [ -n "$HOLDERS" ] || { echo "refusing to render: origin_env names $NAME, which no container in $TD sets" >&2; exit 1; }
32
+ DIFFERENT=$(echo "$DEF" | jq -r --arg n "$NAME" --arg v "$ORIGIN_VALUE" \
33
+ '[.[] | select(any((.environment // [])[]; .name == $n and .value != $v)) | .name] | join(" ")')
34
+ [ -z "$DIFFERENT" ] || { echo "refusing to render: $NAME in $TD (container: $DIFFERENT) differs from the origin secret $ORIGIN_SECRET_ID; the proxy would refuse every request from the CDN. Make them equal." >&2; exit 1; }
35
+ done
36
+ fi
37
+
21
38
  jq --argjson def "$DEF" --arg host "$DB_HOST" --arg secret "$DB_SECRET" '
22
39
  def log: {driver: "json-file", options: {"max-size": "10m", "max-file": "3"}};
23
40
  def env(c): ((c.environment // []) | map({(.name): .value}) | add // {})
@@ -379,9 +379,19 @@ module Hecks
379
379
  def route_blocks(plan)
380
380
  routes = plan.routes.each_with_index.map do |route, i|
381
381
  port = plan.containers.find { |c| c.name == route.container }.port
382
- "@r#{i + 1} path #{route.paths.join(' ')}\nhandle @r#{i + 1} {\n\treverse_proxy 127.0.0.1:#{port}\n}\n\n"
382
+ "@r#{i + 1} path #{route.paths.join(' ')}\nhandle @r#{i + 1} {\n#{upstream(port)}}\n\n"
383
383
  end
384
- "#{routes.join}handle {\n\treverse_proxy 127.0.0.1:#{plan.default.port}\n}\n"
384
+ "#{routes.join}handle {\n#{upstream(plan.default.port)}}\n"
385
+ end
386
+
387
+ # One upstream. A request that arrives while its container is being replaced
388
+ # waits, and is tried again every quarter second for up to 15 seconds, so a
389
+ # deploy shows a visitor a slow page instead of a 502.
390
+ #
391
+ # @param port [Integer] the container's port
392
+ # @return [String] the `reverse_proxy` block, one tab in, ending in a newline
393
+ def upstream(port)
394
+ "\treverse_proxy 127.0.0.1:#{port} {\n\t\tlb_try_duration 15s\n\t\tlb_try_interval 250ms\n\t}\n"
385
395
  end
386
396
 
387
397
  # @param plan [Settings::Plan] the resolved settings
@@ -425,6 +435,7 @@ module Hecks
425
435
  def services_json(plan)
426
436
  services = plan.containers.to_h { |c| [c.name, service_entry(plan, c)] }
427
437
  origin = plan.origin_secret ? { "header" => plan.origin_header, "secret" => plan.origin_secret } : nil
438
+ origin["env"] = plan.origin_env if origin && plan.origin_env.any?
428
439
  document = { "services" => services, "origin" => origin }
429
440
  document["task_definition"] = plan.task_definition if plan.task_definition
430
441
  document["tunnel"] = tunnel_entry(plan.tunnel_service) if plan.tunnel_service
@@ -63,9 +63,11 @@ module Hecks
63
63
 
64
64
  def reference(text) = text.nil? || text == "none" ? nil : Checks.resolve(text)
65
65
 
66
- # CloudFront takes three sets of methods: read, read with `OPTIONS`, and all.
66
+ # CloudFront takes three sets of methods: read, read with `OPTIONS`, and all. A route's
67
+ # `edge_methods` are the verbs the edge lets through; they are its own `methods` unless
68
+ # the row says more.
67
69
  def methods(rows)
68
- verbs = rows.flat_map(&:verbs).uniq
70
+ verbs = rows.flat_map(&:edge_verbs).uniq
69
71
  return Deploy::Fargate::Cdn::METHODS.fetch("all") unless (verbs - ["GET"]).empty?
70
72
 
71
73
  assets = rows.all? { |row| row.origin == "assets" }
@@ -76,7 +78,7 @@ module Hecks
76
78
  # http and redirected; anything else (a form, an API, a signed or admin page, a route of
77
79
  # the cms or the domain) is served over https only.
78
80
  def protocol(row)
79
- plain = row.auth == "public" && row.verbs == ["GET"] && %w[website assets].include?(row.origin)
81
+ plain = row.auth == "public" && row.edge_verbs == ["GET"] && %w[website assets].include?(row.origin)
80
82
  plain ? "redirect-to-https" : "https-only"
81
83
  end
82
84