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.
- checksums.yaml +4 -4
- data/lib/hecks/bluebook/meta_validator/modes.rb +14 -0
- data/lib/hecks/bluebook/meta_validator.rb +4 -2
- data/lib/hecks/corpus/routes.rb +1 -1
- data/lib/hecks/gate/stages.yml +11 -1
- data/lib/hecks/hecks/adapters/site_toolchain/prober.rb +64 -0
- data/lib/hecks/hecks/adapters/site_toolchain.rb +41 -5
- data/lib/hecks/language/bluebook/vocabulary.bluebook +1 -1
- data/lib/hecks/projections/deploy/shared_database_instance/templates/rds.yaml.tmpl +1 -1
- data/lib/hecks/projections/deploy/templates/lambda/template.yaml.tmpl +1 -1
- data/lib/hecks/projections/site/probe.rb +131 -0
- data/lib/hecks/projections/site/site_host/dockerfile.rb +1 -1
- data/lib/hecks/runtime/value/validation.rb +34 -3
- data/lib/hecks/site/bluebook/site.bluebook +31 -0
- data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +1 -1
- data/prebuilt/{verdicts-be73f0df9eee0abe2f4aadc968321c4b919d19d424a3ccfe1777a71dabf7d9e7.json → verdicts-91aaf93ce8981bd9a754948f075d95650dc2ec6435015376f5c60992b36a24b1.json} +1 -1
- data/rust/codegen/src/fielded.rs +3 -3
- data/rust/codegen/src/json_codec.rs +2 -2
- data/rust/codegen/src/naming.rs +20 -0
- data/rust/host/Cargo.toml +4 -0
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/boot.rs +443 -0
- data/rust/host/src/boundary_fuzz/ir.rs +1 -0
- data/rust/host/src/commerce_ir.rs +459 -0
- data/rust/host/src/extension.rs +100 -0
- data/rust/host/src/ir.rs +0 -448
- data/rust/host/src/journal.rs +1 -1
- data/rust/host/src/lib.rs +40 -0
- data/rust/host/src/main.rs +4 -477
- data/rust/host/src/mint.rs +1 -1
- data/rust/host/src/payments/tests.rs +8 -8
- data/rust/host/src/payments.rs +10 -10
- data/rust/host/src/rate_limit.rs +27 -41
- data/rust/host/src/secrets.rs +15 -0
- data/rust/host/src/web/commerce.rs +121 -0
- data/rust/host/src/web/newsletter.rs +2 -1
- data/rust/host/src/web/newsletter_send.rs +1 -1
- data/rust/host/src/web/registration_receipt.rs +2 -2
- data/rust/host/src/web/registrations.rs +49 -49
- data/rust/host/src/web/resend_webhook.rs +300 -0
- data/rust/host/src/web.rs +43 -47
- metadata +13 -5
- /data/prebuilt/{a0818e2de67364dc1ab22868b6cbf869c62da6f8d9b924c778fa5cce2e76ee94.marshal → 0ee7f83cba59ce7d1c45c65ba166b071779f70143449f584f33ff7989f64eccb.marshal} +0 -0
- /data/prebuilt/{945481eefbdb584649d45f256307053558966a868b9c38dee7ee88f8169610f8.marshal → 6e4a6bcf0aa880e2e6b056e1846b70e5a768090b9dc998bbadeaf5d6bcfd3db8.marshal} +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d303c552c29899c933f31445d71ea42063a1b7cc5233cdef4a1d7392aa10201c
|
|
4
|
+
data.tar.gz: caa19c0f1639d2ca386465977a1c099336ee2c1e598399ee3b88122206d3c2b7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 ||=
|
|
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 `||=`
|
data/lib/hecks/corpus/routes.rb
CHANGED
|
@@ -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
|
data/lib/hecks/gate/stages.yml
CHANGED
|
@@ -25,7 +25,17 @@ pre_push:
|
|
|
25
25
|
checks:
|
|
26
26
|
- id: rspec
|
|
27
27
|
title: rspec (parallel)
|
|
28
|
-
run:
|
|
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
|
-
|
|
92
|
-
chapter
|
|
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: "
|
|
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
|
|
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/
|
|
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
|
-
#
|
|
92
|
-
return
|
|
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,
|
|
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 }
|
data/lib/hecks/version.rb
CHANGED
data/lib/hecks/vocabulary.rb
CHANGED
|
@@ -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"=>"
|
|
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,
|