hecks 3.7.0 → 3.9.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/bluebook/meta_validator/modes.rb +14 -0
  3. data/lib/hecks/bluebook/meta_validator.rb +4 -2
  4. data/lib/hecks/corpus/routes.rb +1 -1
  5. data/lib/hecks/gate/stages.yml +11 -1
  6. data/lib/hecks/hecks/adapters/site_toolchain/prober.rb +64 -0
  7. data/lib/hecks/hecks/adapters/site_toolchain.rb +41 -5
  8. data/lib/hecks/language/bluebook/vocabulary.bluebook +1 -1
  9. data/lib/hecks/projections/deploy/shared_database_instance/templates/rds.yaml.tmpl +1 -1
  10. data/lib/hecks/projections/deploy/templates/lambda/template.yaml.tmpl +1 -1
  11. data/lib/hecks/projections/site/probe.rb +131 -0
  12. data/lib/hecks/projections/site/site_host/dockerfile.rb +1 -1
  13. data/lib/hecks/runtime/value/validation.rb +34 -3
  14. data/lib/hecks/site/bluebook/site.bluebook +31 -0
  15. data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
  16. data/lib/hecks/version.rb +1 -1
  17. data/lib/hecks/vocabulary.rb +1 -1
  18. data/prebuilt/{verdicts-be73f0df9eee0abe2f4aadc968321c4b919d19d424a3ccfe1777a71dabf7d9e7.json → verdicts-91aaf93ce8981bd9a754948f075d95650dc2ec6435015376f5c60992b36a24b1.json} +1 -1
  19. data/rust/codegen/src/fielded.rs +3 -3
  20. data/rust/codegen/src/json_codec.rs +2 -2
  21. data/rust/codegen/src/naming.rs +20 -0
  22. data/rust/host/Cargo.toml +4 -0
  23. data/rust/host/HECKS_RELEASE +1 -1
  24. data/rust/host/src/boot.rs +443 -0
  25. data/rust/host/src/boundary_fuzz/ir.rs +1 -0
  26. data/rust/host/src/commerce_ir.rs +459 -0
  27. data/rust/host/src/extension.rs +100 -0
  28. data/rust/host/src/ir.rs +0 -448
  29. data/rust/host/src/journal.rs +1 -1
  30. data/rust/host/src/lib.rs +40 -0
  31. data/rust/host/src/main.rs +4 -477
  32. data/rust/host/src/mint.rs +1 -1
  33. data/rust/host/src/payments/tests.rs +8 -8
  34. data/rust/host/src/payments.rs +10 -10
  35. data/rust/host/src/rate_limit.rs +27 -41
  36. data/rust/host/src/secrets.rs +15 -0
  37. data/rust/host/src/web/commerce.rs +121 -0
  38. data/rust/host/src/web/newsletter.rs +2 -1
  39. data/rust/host/src/web/newsletter_send.rs +1 -1
  40. data/rust/host/src/web/registration_receipt.rs +2 -2
  41. data/rust/host/src/web/registrations.rs +49 -49
  42. data/rust/host/src/web/resend_webhook.rs +300 -0
  43. data/rust/host/src/web.rs +43 -47
  44. metadata +13 -5
  45. /data/prebuilt/{a0818e2de67364dc1ab22868b6cbf869c62da6f8d9b924c778fa5cce2e76ee94.marshal → 0ee7f83cba59ce7d1c45c65ba166b071779f70143449f584f33ff7989f64eccb.marshal} +0 -0
  46. /data/prebuilt/{945481eefbdb584649d45f256307053558966a868b9c38dee7ee88f8169610f8.marshal → 6e4a6bcf0aa880e2e6b056e1846b70e5a768090b9dc998bbadeaf5d6bcfd3db8.marshal} +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3b528b48266e3984df1fa1d14210984272471586c775cf95487c883edc5552c0
4
- data.tar.gz: dae0ec3b22400a3d8dc8764228ffce3d3e845dc833204dc26c70eac221b78fc4
3
+ metadata.gz: d303c552c29899c933f31445d71ea42063a1b7cc5233cdef4a1d7392aa10201c
4
+ data.tar.gz: caa19c0f1639d2ca386465977a1c099336ee2c1e598399ee3b88122206d3c2b7
5
5
  SHA512:
6
- metadata.gz: 1f42899b69da5c2ff3a5dd7b10b1dfbe75623725b739111f4004f28d3a651a151f8d56abdafc71cbcbcda17de05d6f474b59e6bfe7f9c7726f0047018fbe83e3
7
- data.tar.gz: 870a7b4c6c636b3a0dd1ab16773c55da1effc9ed6e51f3a0bfc5721cea3b0b9ff237d721f98227d95b253f7c714d1499a34740c476e17f9cda609902e1db6a13
6
+ metadata.gz: 3b2ee56f395214eee7e018c8ba28650b8f5f9ec97cc683b705037e19e37c96246fa4a380bbedb2aec9189ccb0361f3ef5f32e7af07cd5808531c1891e6e3011c
7
+ data.tar.gz: deb31a80d9d10f30898074608b3158e2e916989160d69cb632777c14dae4305dd2fe43d5da8561024625c0e24175fab7fc7998469854e00f16561ef1e2dd59cf
@@ -118,6 +118,20 @@ module Hecks
118
118
  @shadow_parsing = previous
119
119
  end
120
120
 
121
+ # Wraps `block` with `shadow_parsing?` false, restoring it afterward. The language's own
122
+ # grammar is built once per process and memoized, so it cannot take the mode of whichever
123
+ # caller happens to ask for it first.
124
+ #
125
+ # @yield the one-time grammar build, with `shadow_parsing?` false throughout
126
+ # @return [Object] the block's own return value
127
+ def while_not_shadow_parsing
128
+ previous = @shadow_parsing
129
+ @shadow_parsing = false
130
+ yield
131
+ ensure
132
+ @shadow_parsing = previous
133
+ end
134
+
121
135
  # Wraps `block` with `disabled?` forced false, restoring it afterward.
122
136
  # Only `grammar_registry`'s one-time fixpoint build uses this.
123
137
  #
@@ -168,12 +168,14 @@ module Hecks
168
168
  end
169
169
 
170
170
  # The booted, self-judged language registry every ordinary bluebook is
171
- # judged against — built once per process and memoized.
171
+ # judged against — built once per process and memoized, with shadow parsing off: a caller
172
+ # inside `while_shadow_parsing` that is first to ask would otherwise memoize a grammar
173
+ # parsed in that mode, or fail to load it.
172
174
  #
173
175
  # @return [Runtime::Registry] the booted, fixpoint-judged language
174
176
  # registry
175
177
  def self.grammar_registry
176
- @grammar_registry ||= begin
178
+ @grammar_registry ||= while_not_shadow_parsing do
177
179
  registry = load_grammar_into(Runtime::Registry.new)
178
180
  # Assigned before the fixpoint judge below, since judging re-enters
179
181
  # grammar_registry (via fresh_runtime/Plan.for) and a bare `||=`
@@ -27,7 +27,7 @@ module Hecks
27
27
  # hecks fuzz sweeps it too, once Fuzzing::Replay coerces value-object args
28
28
  # before recomputing givens — today it doesn't, so this given reads as
29
29
  # wrongly admitted.
30
- Route.new(%r{\Aspec/fixtures/rust_host/}, :named_in, "rust/host/src/web.rs", "checkout_fixture",
30
+ Route.new(%r{\Aspec/fixtures/rust_host/}, :named_in, "rust/host/src/web/commerce.rs", "checkout_fixture",
31
31
  "the Rust host's checkout fixture, pinned by its web and /api tests")
32
32
  ].freeze
33
33
  end
@@ -25,7 +25,17 @@ pre_push:
25
25
  checks:
26
26
  - id: rspec
27
27
  title: rspec (parallel)
28
- run: [bundle, exec, parallel_rspec, spec, -n, "{workers}"]
28
+ run:
29
+ - sh
30
+ - -ec
31
+ - |
32
+ # Workers are grouped by this machine's own last timings, kept under the git directory
33
+ # (shared by every worktree, never committed). CI's committed baseline leaves out the io
34
+ # specs this run includes, so it only seeds the first run; each run then rewrites the log.
35
+ log="$(git rev-parse --git-common-dir)/hecks_rspec_runtime.log"
36
+ [ -f "$log" ] || cp .github/rspec_runtime_baseline.log "$log"
37
+ bundle exec parallel_rspec spec -n {workers} --group-by runtime --runtime-log "$log" --allowed-missing 100 \
38
+ -o "--format ParallelTests::RSpec::RuntimeLogger --out $log"
29
39
  blocked: the suite is red.
30
40
  - &check_fuzzing
31
41
  id: fuzzing
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require_relative "../../../projections/site/probe"
6
+
7
+ module Hecks
8
+ module Adapters
9
+ class SiteToolchain
10
+ # Sends a route table's probe requests to a running site and reports each answer.
11
+ #
12
+ # Every request is anonymous and follows no redirect, so a probe changes nothing on the site.
13
+ class Prober
14
+ # Seconds to wait to connect to the site, and again for its answer.
15
+ TIMEOUT = 10
16
+
17
+ # The failures of the network that fail one check rather than the whole run.
18
+ NETWORK = [SystemCallError, Timeout::Error, SocketError, OpenSSL::SSL::SSLError].freeze
19
+
20
+ # @param rows [Array<Projections::Site::Table::Row>] the checked rows of the route table
21
+ # @param run [String] the run's name
22
+ # @param base [String] the site's scheme, host and port
23
+ def initialize(rows, run:, base:)
24
+ @probe = Projections::Site::Probe.new(rows, run: run)
25
+ @base = base
26
+ end
27
+
28
+ # @return [Array<(String, Integer)>] the report, one line per check ending in the count,
29
+ # and how many checks failed
30
+ def call
31
+ lines = @probe.checks.map { |check| judged(check) }
32
+ failed = lines.count { |_, wrong| wrong }
33
+ [lines.map(&:first).append("#{lines.size} checks, #{failed} failed").join("\n"), failed]
34
+ end
35
+
36
+ private
37
+
38
+ # One check's line of the report: its label and `ok`, or the reason it failed.
39
+ def judged(check)
40
+ why = @probe.verdict(check, answer_to(check))
41
+ [" #{check.label}... #{why ? "FAILED: #{why}" : "ok"}", !why.nil?]
42
+ rescue *NETWORK => e
43
+ [" #{check.label}... FAILED: #{e.class}: #{e.message}", true]
44
+ end
45
+
46
+ def answer_to(check)
47
+ uri = URI.join(@base, check.path)
48
+ request = Net::HTTP.const_get(check.verb.capitalize).new(uri)
49
+ request["Origin"] = @base
50
+ response = connection(uri).request(request)
51
+ Projections::Site::Probe::Response.new(status: response.code.to_i, body: response.body,
52
+ headers: response.to_hash.transform_values(&:first))
53
+ end
54
+
55
+ def connection(uri)
56
+ Net::HTTP.new(uri.host, uri.port).tap do |http|
57
+ http.use_ssl = uri.scheme == "https"
58
+ http.open_timeout = http.read_timeout = TIMEOUT
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -8,6 +8,7 @@ require_relative "codebase/ruby_child"
8
8
  require "hecks/tools"
9
9
  require "hecks/tools/site_routes"
10
10
  require "hecks/projections/site/live_edge"
11
+ require_relative "site_toolchain/prober"
11
12
 
12
13
  module Hecks
13
14
  module Adapters
@@ -66,8 +67,35 @@ module Hecks
66
67
  { output: { value: comparison.to_s } }
67
68
  end
68
69
 
70
+ # Asks a running site what its route table says it must answer, and judges each answer. It
71
+ # sends anonymous requests only, follows no redirect, and changes nothing on the site: an
72
+ # admin route is asked without a session and must refuse.
73
+ #
74
+ # @param held [Hash] the `SiteProjection` record: `domain`, `run` and `url`, the site's base
75
+ # address
76
+ # @return [Hash{Symbol => Hash}] `output:` one line per check, ending in the count
77
+ # @raise [ConsoleCapture::Failure] when the address is not http(s) or an answer is wrong; the
78
+ # message is the report
79
+ def probe(**held)
80
+ rows = table_of(located(:domain, held[:domain])).rows
81
+ report, failed = Prober.new(rows, run: plain(held[:run]).to_s, base: base_url(plain(held[:url]))).call
82
+ raise ConsoleCapture::Failure, report if failed.positive?
83
+
84
+ { output: { value: report } }
85
+ end
86
+
69
87
  private
70
88
 
89
+ # The scheme, host and port of an http(s) address.
90
+ def base_url(url)
91
+ uri = URI.parse(url.to_s)
92
+ raise URI::InvalidURIError unless uri.is_a?(URI::HTTP) && uri.host
93
+
94
+ "#{uri.scheme}://#{uri.host}#{":#{uri.port}" unless uri.port == uri.default_port}"
95
+ rescue URI::InvalidURIError
96
+ raise ConsoleCapture::Failure, "url must be an http(s) address, got #{url.inspect}"
97
+ end
98
+
71
99
  # The flags `project_site` is run with: each the record names, and `--check` when asked.
72
100
  def project_flags(held)
73
101
  flags = FLAGS.filter_map { |flag, key| "--#{flag}=#{located(key, held[key])}" unless plain(held[key]).nil? }
@@ -84,15 +112,23 @@ module Hecks
84
112
 
85
113
  # The project's checked edge, read the way `project_site` reads it.
86
114
  def edge_of(root, template = nil)
115
+ registry, chapter, table = read_table(root)
116
+ site = Projections::Site
117
+ site::Edge.read(chapter, table: table, template: template, vocabulary: site::Table.vocabulary(registry)) ||
118
+ raise(ConsoleCapture::Failure, "#{root} declares no Edge rows")
119
+ end
120
+
121
+ # The project's checked route table.
122
+ def table_of(root) = read_table(root).last
123
+
124
+ # The registry, chapter and checked table of the project at `root`.
125
+ def read_table(root)
87
126
  registry = nil
88
127
  outcome = ConsoleCapture.capture { registry = Tools::SiteRoutes.registry_for(root) }
89
128
  raise ConsoleCapture::Failure, outcome.output.strip unless outcome.ok?
90
129
 
91
- site = Projections::Site
92
- chapter = site::Table.chapter(registry)
93
- table = site::Table.read(chapter, registry: registry)
94
- site::Edge.read(chapter, table: table, template: template, vocabulary: site::Table.vocabulary(registry)) ||
95
- raise(ConsoleCapture::Failure, "#{root} declares no Edge rows")
130
+ chapter = Projections::Site::Table.chapter(registry)
131
+ [registry, chapter, Projections::Site::Table.read(chapter, registry: registry)]
96
132
  rescue Projections::Site::Table::Invalid => e
97
133
  raise ConsoleCapture::Failure, e.message
98
134
  end
@@ -626,7 +626,7 @@ Hecks.bluebook "Bluebook" do
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
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
- push: "before_sha",
629
+ push: "skip",
630
630
  label: "does this change touch anything rspec_postgres_io_parallel covers?"
631
631
  end
632
632
 
@@ -58,7 +58,7 @@ Resources:
58
58
  DbSecurityGroup:
59
59
  Type: AWS::EC2::SecurityGroup
60
60
  Properties:
61
- GroupDescription: @@STACK@@ RDS, 5432 from each site's box (their stacks add the rules) and a bastion if given
61
+ GroupDescription: @@STACK@@ RDS, 5432 from each site box (the box stacks add the rules) and a bastion if given
62
62
  VpcId: !Ref VpcId
63
63
 
64
64
  BastionIngress:
@@ -126,7 +126,7 @@ Resources:
126
126
  # triggers that first boot; a LATER real schema evolution
127
127
  # (era 2+) is a separate, later re-generation, not this one.
128
128
  HECKS_DOMAIN: @@declared_domain_name@@
129
- # UNCONDITIONAL, not gated on `rust_web` — `rust/host/src/main.rs`'s
129
+ # UNCONDITIONAL, not gated on `rust_web` — `rust/host/src/boot.rs`'s
130
130
  # own boot sequence reads `ir::ir().ok_or(...)?` for every domain
131
131
  # regardless of web mode (era-lineage bookkeeping needs the IR, not
132
132
  # just the optional in-process web UI), so a Shared-mode domain with
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hecks
4
+ module Projections
5
+ module Site
6
+ # The requests a live site must answer as its route table says, and the verdict on each
7
+ # answer. It sends nothing: the caller makes the requests and hands each response back.
8
+ # A route with a parameter or a prefix (`/blog/:slug.html`, `/auth/*`) has no one URL to ask.
9
+ #
10
+ # - an `admin` route refuses an anonymous GET (and POST, where it takes one), uncached
11
+ # - an indexable `public` page answers 200 and names its canonical link
12
+ # - a row that is `off` answers 404, and a `redirect` row redirects to `redirect_to`
13
+ # - a path no row declares answers 404
14
+ class Probe
15
+ # One request to make and what a good answer to it looks like.
16
+ #
17
+ # @!attribute [r] label
18
+ # @return [String] what is being checked, for the report
19
+ # @!attribute [r] verb
20
+ # @return [String] GET or POST
21
+ # @!attribute [r] path
22
+ # @return [String] the path to ask
23
+ # @!attribute [r] expect
24
+ # @return [Symbol] one of `:refused`, `:page`, `:missing`, `:redirect`
25
+ # @!attribute [r] to
26
+ # @return [String, nil] where a `:redirect` must lead
27
+ Check = Struct.new(:label, :verb, :path, :expect, :to, keyword_init: true)
28
+
29
+ # What one answer was: its status, its headers (lower-case names) and its body.
30
+ Response = Struct.new(:status, :headers, :body, keyword_init: true)
31
+
32
+ # The statuses that turn an anonymous caller away from an admin route.
33
+ REFUSED = [301, 302, 303, 307, 308, 401, 403].freeze
34
+
35
+ # The redirect statuses.
36
+ REDIRECTS = [301, 302, 303, 307, 308].freeze
37
+
38
+ # @param rows [Array<Table::Row>] the checked rows of a route table
39
+ # @param run [String] the run's name, which makes the missing path unique to it
40
+ def initialize(rows, run:)
41
+ @rows = rows
42
+ @run = run
43
+ end
44
+
45
+ # @return [Array<Check>] every request worth making, in the table's order, then the
46
+ # path that must not exist
47
+ def checks
48
+ @rows.select { |row| askable?(row) }.flat_map { |row| for_row(row) } + [missing]
49
+ end
50
+
51
+ # Judges one answer.
52
+ #
53
+ # @param check [Check] the request that was made
54
+ # @param response [Response] what came back
55
+ # @return [String, nil] why the answer is wrong, or nil when it is right
56
+ def verdict(check, response)
57
+ case check.expect
58
+ when :refused then refused(response)
59
+ when :page then page(response)
60
+ when :missing then response.status == 404 ? nil : "expected 404, got HTTP #{response.status}"
61
+ when :redirect then redirect(check, response)
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def askable?(row) = !row.path.empty? && !row.path.match?(/[:*]/)
68
+
69
+ def for_row(row)
70
+ return [off(row)] if row.off
71
+ return admin(row) if row.auth == "admin"
72
+ return [redirecting(row)] if row.kind == "redirect" && row.redirect_to
73
+ return [page_of(row)] if page?(row)
74
+
75
+ []
76
+ end
77
+
78
+ def page?(row) = row.auth == "public" && row.kind == "page" && row.indexable && row.verbs.include?("GET")
79
+
80
+ def off(row) = Check.new(label: "#{row.path} is switched off: 404", verb: "GET", path: row.path, expect: :missing)
81
+
82
+ def admin(row)
83
+ row.verbs.select { |verb| %w[GET POST].include?(verb) }.map do |verb|
84
+ Check.new(label: "#{verb} #{row.path} without a session is refused and not cached",
85
+ verb: verb, path: row.path, expect: :refused)
86
+ end
87
+ end
88
+
89
+ def redirecting(row)
90
+ Check.new(label: "#{row.path} redirects to #{row.redirect_to}", verb: "GET", path: row.path,
91
+ expect: :redirect, to: row.redirect_to)
92
+ end
93
+
94
+ def page_of(row)
95
+ Check.new(label: "#{row.path} answers 200 with a canonical link", verb: "GET", path: row.path, expect: :page)
96
+ end
97
+
98
+ def missing
99
+ Check.new(label: "a path no row declares is a real 404", verb: "GET", path: "/probe-#{@run}-missing",
100
+ expect: :missing)
101
+ end
102
+
103
+ def refused(response)
104
+ return "expected a redirect, 401 or 403, got HTTP #{response.status}" unless REFUSED.include?(response.status)
105
+ return nil if response.status >= 400
106
+
107
+ control = response.headers["cache-control"].to_s
108
+ return nil unless control.match?(/public|max-age=[1-9]/) && !control.match?(/no-store|private/)
109
+
110
+ "the redirect is cacheable (Cache-Control: #{control})"
111
+ end
112
+
113
+ def page(response)
114
+ return "expected 200, got HTTP #{response.status}" unless response.status == 200
115
+ return nil if response.body.to_s.match?(/<link[^>]+rel=["']canonical["']/i)
116
+
117
+ "the page has no canonical link"
118
+ end
119
+
120
+ def redirect(check, response)
121
+ return "expected a redirect, got HTTP #{response.status}" unless REDIRECTS.include?(response.status)
122
+
123
+ location = response.headers["location"].to_s
124
+ return nil if location == check.to || (location.end_with?(check.to) && location.start_with?("http"))
125
+
126
+ "redirects to #{location.inspect}, expected #{check.to.inspect}"
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -52,7 +52,7 @@ module Hecks
52
52
 
53
53
  COPY deploy-aws/boot.mjs ./
54
54
  # RDS enforces TLS; boot.mjs verifies against Amazon's CA bundle.
55
- ADD https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem ./rds-global-bundle.pem
55
+ ADD --chmod=0644 https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem ./rds-global-bundle.pem
56
56
 
57
57
  # boot.mjs sits outside the Next app, so `next build` does not trace its import of the AWS SDK.
58
58
  COPY --from=build /app/node_modules/@aws-sdk ./node_modules/@aws-sdk
@@ -88,8 +88,8 @@ module Hecks
88
88
  def normalize_composite_field(aggregate, attribute, fields)
89
89
  # A list member hydrates the same as a top-level list, so a value
90
90
  # read back from the store matches the shape a live dispatch wrote.
91
- # Load door only — an input list member is left as offered.
92
- return hydrate_stored_list(aggregate, attribute, fields) if attribute.list?
91
+ # An input list member is validated as offered and left as offered.
92
+ return normalize_list(aggregate, attribute, fields) if attribute.list?
93
93
 
94
94
  raw = fields[attribute.name]
95
95
  return if raw.nil? || raw.is_a?(self)
@@ -103,15 +103,46 @@ module Hecks
103
103
  fields[attribute.name] = nested_fields
104
104
  end
105
105
 
106
+ def normalize_list(aggregate, attribute, fields)
107
+ return hydrate_stored_list(aggregate, attribute, fields) if trusting_stored_state?
108
+
109
+ validate_input_list(aggregate, attribute, fields)
110
+ end
111
+
112
+ # Runs each value-object member of an input list through the same door a nested single
113
+ # value goes through, so a member's own invariants and nested lists refuse on dispatch
114
+ # exactly as they do when the list sits directly on a command. The members stay as offered.
115
+ def validate_input_list(aggregate, attribute, fields)
116
+ nested = value_object_for(aggregate, attribute.type)
117
+ return unless nested
118
+
119
+ Array(fields[attribute.name]).each do |member|
120
+ next unless member.is_a?(Hash)
121
+
122
+ member_fields = apply_defaults(nested, fields_for(nested, attribute.name, member))
123
+ validate!(nested, normalize_composite_fields(aggregate, nested, member_fields))
124
+ end
125
+ end
126
+
106
127
  def hydrate_stored_list(aggregate, attribute, fields)
107
128
  return unless trusting_stored_state?
108
129
 
109
130
  fields[attribute.name] = for_attribute(aggregate, attribute, fields[attribute.name])
110
131
  end
111
132
 
133
+ # An optional attribute the caller left out reads as nil in an invariant, so a rule can
134
+ # ask `x.nil?` or `x.set?` of it instead of failing to resolve a key that was never sent.
135
+ def with_absent_optionals(value_object, fields)
136
+ absent = value_object.attributes.select { |attribute| attribute.optional? && !fields.key?(attribute.name) }
137
+ return fields if absent.empty?
138
+
139
+ absent.to_h { |attribute| [attribute.name, nil] }.merge(fields)
140
+ end
141
+
112
142
  def check_invariants(value_object, fields)
143
+ known = with_absent_optionals(value_object, fields)
113
144
  value_object.invariants.each do |invariant|
114
- next if Bluebook::Expression::Evaluator.call_rule(invariant, fields)
145
+ next if Bluebook::Expression::Evaluator.call_rule(invariant, known)
115
146
 
116
147
  raise InvariantViolation,
117
148
  RefusalWording.render_site("InvariantViolation", "value_object_invariant",
@@ -107,6 +107,7 @@ Hecks.bluebook "Site" do
107
107
  attribute :distribution, DistributionId, optional: true
108
108
  attribute :expect_new, PathList, optional: true
109
109
  attribute :refs, PolicyRefs, optional: true
110
+ attribute :url, SiteUrl, optional: true
110
111
  attribute :output, Report, optional: true
111
112
  attribute :refusal, Report, optional: true
112
113
  identified_by :run
@@ -155,6 +156,11 @@ Hecks.bluebook "Site" do
155
156
  attribute :value, String, pattern: '[^\t\n\r]'
156
157
  end
157
158
 
159
+ # A running site's address, `https://example.org`: what `check_site` sends its requests to.
160
+ value_object "SiteUrl" do
161
+ attribute :value, String, pattern: '^https?://[^ \t\n\r]+$'
162
+ end
163
+
158
164
  value_object "Report" do
159
165
  attribute :value, String
160
166
  end
@@ -224,6 +230,26 @@ Hecks.bluebook "Site" do
224
230
  emits "LiveCheckRequested"
225
231
  end
226
232
 
233
+ # `hecks site site_projection.check_site <project> url=<https://example.org>`. Asks a running
234
+ # site what its route table says it must answer, with anonymous requests that follow no
235
+ # redirect and change nothing: an admin route refuses, an indexable public page answers 200
236
+ # with a canonical link, an `off` row and an undeclared path answer 404, and a redirect row
237
+ # redirects to its target. Routes that name a parameter or a prefix are left out. It prints a
238
+ # line per check and exits 1 when any answer is wrong.
239
+ command "CheckSite" do
240
+ goal "Ask a running site what its route table says it must answer, and judge each answer"
241
+
242
+ attribute :domain, DomainPath
243
+ attribute :run, RunKey
244
+ attribute :url, SiteUrl
245
+
246
+ sets :domain
247
+ sets :run
248
+ sets :url
249
+
250
+ emits "SiteProbeRequested"
251
+ end
252
+
227
253
  command "Complete" do
228
254
  goal "Record that routes.ts was written, or found current, or the live edge matched, and what it said"
229
255
 
@@ -270,6 +296,11 @@ Hecks.bluebook "Site" do
270
296
  trigger SiteProjection::SiteToolchain::Compare, with: { run: :run }
271
297
  end
272
298
 
299
+ policy "ProbeWhenRequested" do
300
+ on "SiteProjection.SiteProbeRequested"
301
+ trigger SiteProjection::SiteToolchain::Probe, with: { run: :run }
302
+ end
303
+
273
304
  policy "RecordTheProjection" do
274
305
  on "SiteProjection.SiteAnswered"
275
306
  trigger SiteProjection::Complete, with: { output: :output }
@@ -17,5 +17,11 @@ Hecks.hecksagon "Site" do
17
17
  answers "SiteAnswered"
18
18
  refuses "SiteRefused"
19
19
  end
20
+
21
+ asks "Probe", to: SiteProjection do
22
+ attribute :run, RunKey
23
+ answers "SiteAnswered"
24
+ refuses "SiteRefused"
25
+ end
20
26
  end
21
27
  end
data/lib/hecks/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  module Hecks
2
2
  # Read directly by the gemspec, so this file must never require the
3
3
  # rest of the gem (its dependencies aren't resolved yet at that point).
4
- VERSION = "3.7.0".freeze
4
+ VERSION = "3.9.1".freeze
5
5
  end
@@ -51,7 +51,7 @@ module Hecks
51
51
  ].freeze,
52
52
  "CiGate" => [
53
53
  {"name"=>"runtime_changed", "workflow"=>"ci.yml", "mode"=>"touches", "pattern"=>"^lib/hecks/runtime/", "push"=>"before_sha", "label"=>"does this change touch lib/hecks/runtime/**?"}.freeze,
54
- {"name"=>"postgres_io_relevant_changed", "workflow"=>"ci-postgres-io-parallel.yml", "mode"=>"skips_unless", "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$)", "push"=>"before_sha", "label"=>"does this change touch anything rspec_postgres_io_parallel covers?"}.freeze
54
+ {"name"=>"postgres_io_relevant_changed", "workflow"=>"ci-postgres-io-parallel.yml", "mode"=>"skips_unless", "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$)", "push"=>"skip", "label"=>"does this change touch anything rspec_postgres_io_parallel covers?"}.freeze
55
55
  ].freeze,
56
56
  "Comparison" => [
57
57
  {"symbol"=>">=", "compares_less_than"=>"true", "compares_equal"=>"false", "negated"=>"true"}.freeze,