hecks 3.1.3 → 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.
- checksums.yaml +4 -4
- data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +3 -3
- data/lib/hecks/cli/check_era.rb +3 -39
- data/lib/hecks/deploy/bluebook/deploy.bluebook +105 -0
- data/lib/hecks/deploy/bluebook/deploy.hecksagon +8 -0
- data/lib/hecks/deploy/oidc.json +15 -0
- data/lib/hecks/doors/mcp_door.rb +16 -2
- data/lib/hecks/doors/mcp_door_scope.rb +43 -4
- data/lib/hecks/doors/mcp_guide_cache.rb +75 -0
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +17 -1
- data/lib/hecks/hecks/adapters/codebase/test_runner.rb +10 -2
- data/lib/hecks/hecks/adapters/cost_explorer.adapter +7 -0
- data/lib/hecks/hecks/adapters/cost_explorer.rb +102 -0
- data/lib/hecks/hecks/adapters/rust_workspace.rb +1 -1
- data/lib/hecks/hecks/adapters/site_toolchain.rb +21 -11
- data/lib/hecks/hecks/hecks.hecksagon +8 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +7 -7
- data/lib/hecks/language/hecksagon/hecksagon.bluebook +1 -1
- data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +3 -3
- data/lib/hecks/projections/deploy/box/settings.rb +23 -2
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +3 -1
- data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +17 -0
- data/lib/hecks/projections/deploy/box.rb +13 -2
- data/lib/hecks/projections/site/edge/behaviours.rb +5 -3
- data/lib/hecks/projections/site/edge/checks.rb +31 -5
- data/lib/hecks/projections/site/edge.rb +25 -11
- data/lib/hecks/projections/site/routes_ts.rb +50 -27
- data/lib/hecks/projections/site/site_cdn.rb +7 -4
- data/lib/hecks/projections/site/table/checks.rb +53 -7
- data/lib/hecks/projections/site/table/link_builder.rb +64 -0
- data/lib/hecks/projections/site/table/row_builder.rb +21 -6
- data/lib/hecks/projections/site/table.rb +16 -5
- data/lib/hecks/rust_build/kernel_input.rb +18 -6
- data/lib/hecks/rust_build/project_rust.rb +6 -132
- data/lib/hecks/site/bluebook/site.bluebook +27 -13
- data/lib/hecks/storehouse.rb +35 -1
- data/lib/hecks/tools/comment_style.rb +4 -2
- data/lib/hecks/tools/deploy_recipe.rb +1 -0
- data/lib/hecks/tools/rust_comment_style.rb +1 -1
- data/lib/hecks/tools/site_routes.rb +69 -28
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +1 -1
- data/lib/hecks.rb +1 -0
- data/rust/build/src/lineage_pass.rs +1 -1
- data/rust/build/src/pipeline.rs +1 -1
- data/rust/codegen/src/attr.rs +1 -1
- data/rust/codegen/src/bridging.rs +1 -1
- data/rust/codegen/src/commands.rs +1 -1
- data/rust/codegen/src/constraints.rs +1 -1
- data/rust/codegen/src/dependency_planning.rs +1 -1
- data/rust/codegen/src/domain_generator.rs +2 -2
- data/rust/codegen/src/exemplar.rs +1 -1
- data/rust/codegen/src/expr_emitter.rs +1 -1
- data/rust/codegen/src/fielded.rs +1 -1
- data/rust/codegen/src/json_codec.rs +1 -1
- data/rust/codegen/src/main.rs +3 -4
- data/rust/codegen/src/mutations.rs +1 -1
- data/rust/codegen/src/naming.rs +1 -1
- data/rust/codegen/src/ports.rs +1 -1
- data/rust/codegen/src/queries.rs +3 -3
- data/rust/codegen/src/reactions.rs +1 -1
- data/rust/codegen/src/read_models.rs +2 -2
- data/rust/codegen/src/reference_specs.rs +1 -1
- data/rust/codegen/src/registry.rs +4 -4
- data/rust/codegen/src/shared.rs +1 -1
- data/rust/codegen/src/skip_reason.rs +1 -1
- data/rust/codegen/src/types.rs +4 -4
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/auth.rs +25 -1
- data/rust/host/src/needs.rs +94 -27
- data/rust/host/src/web.rs +9 -5
- data/rust/src/exemplar/commands.rs +1 -1
- data/rust/src/exemplar/constraints.rs +1 -1
- data/rust/src/exemplar/fielded.rs +1 -1
- data/rust/src/exemplar/json.rs +1 -1
- data/rust/src/exemplar/mod.rs +1 -1
- data/rust/src/exemplar/mutations.rs +1 -1
- data/rust/src/exemplar/queries.rs +1 -1
- data/rust/src/exemplar/reactions.rs +1 -1
- data/rust/src/exemplar/read_models.rs +1 -1
- data/rust/src/exemplar/registry.rs +1 -1
- data/rust/src/exemplar/types.rs +1 -1
- data/rust/src/kernel/cli.rs +2 -2
- data/rust/src/kernel/read_model.rs +1 -1
- data/rust/src/lib.rs +1 -1
- metadata +5 -23
- data/rust/project/bridging.rb +0 -257
- data/rust/project/commands.rb +0 -877
- data/rust/project/constraints.rb +0 -86
- data/rust/project/dependency_planning.rb +0 -178
- data/rust/project/domain_generator.rb +0 -983
- data/rust/project/exemplar.rb +0 -148
- data/rust/project/expr_emitter.rb +0 -72
- data/rust/project/fielded.rb +0 -141
- data/rust/project/json_codec.rb +0 -641
- data/rust/project/mutations.rb +0 -695
- data/rust/project/naming.rb +0 -223
- data/rust/project/ports.rb +0 -65
- data/rust/project/queries.rb +0 -493
- data/rust/project/reactions.rb +0 -317
- data/rust/project/read_models.rb +0 -690
- data/rust/project/reference_specs.rb +0 -27
- data/rust/project/registry.rb +0 -396
- data/rust/project/skip_reason.rb +0 -29
- data/rust/project/types.rb +0 -225
- data/rust/project/write_if_changed.rb +0 -7
- data/rust/project.rb +0 -30
- data/rust/project_rust_pipeline.rb +0 -391
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../edge/pattern"
|
|
4
|
+
|
|
3
5
|
module Hecks
|
|
4
6
|
module Projections
|
|
5
7
|
module Site
|
|
6
8
|
class Table
|
|
7
9
|
# What no single row can show, and what a row contradicts in itself once its defaults are
|
|
8
|
-
# filled: a path declared twice, a switch the rows disagree on,
|
|
9
|
-
#
|
|
10
|
+
# filled: a path declared twice, a switch the rows disagree on, a navigation entry that
|
|
11
|
+
# cannot be linked to, an indexable route that is not public, a public route beneath a
|
|
12
|
+
# prefix that is not.
|
|
10
13
|
class Checks
|
|
11
14
|
SWITCH_ID = /\A[a-z][a-z0-9_]*\z/
|
|
15
|
+
FRAGMENT = /\A[A-Za-z0-9][\w:.-]*\z/
|
|
12
16
|
PUBLIC_MENUS = %w[desktop mobile footer].freeze
|
|
13
17
|
|
|
14
18
|
# The navigation slots a row sits in.
|
|
15
19
|
#
|
|
16
|
-
# @param row [Row] a row
|
|
20
|
+
# @param row [Row, Link] a row or link
|
|
17
21
|
# @return [Array<String>] some of `desktop`, `mobile`, `footer`, `admin`
|
|
18
22
|
def self.navigation(row)
|
|
19
23
|
{ "desktop" => row.nav_order, "mobile" => row.mobile_order, "footer" => row.footer_column,
|
|
@@ -22,8 +26,10 @@ module Hecks
|
|
|
22
26
|
|
|
23
27
|
# @param rows [Array<Row>] the table's rows, defaults filled in
|
|
24
28
|
# @param problems [Array<String>] collects each problem found, one line each
|
|
25
|
-
|
|
29
|
+
# @param links [Array<Link>] the table's extra navigation entries
|
|
30
|
+
def initialize(rows, problems, links: [])
|
|
26
31
|
@rows = rows
|
|
32
|
+
@links = links
|
|
27
33
|
@problems = problems
|
|
28
34
|
end
|
|
29
35
|
|
|
@@ -32,7 +38,8 @@ module Hecks
|
|
|
32
38
|
check_duplicate_paths
|
|
33
39
|
check_switches
|
|
34
40
|
@rows.each { |row| check_row(row) }
|
|
35
|
-
|
|
41
|
+
@links.each { |link| check_link(link) }
|
|
42
|
+
check_unique((@rows + @links).filter_map(&:admin_key), "admin key")
|
|
36
43
|
check_unique(@rows.map(&:source).grep(/\Aglobal:/), "source")
|
|
37
44
|
end
|
|
38
45
|
|
|
@@ -63,6 +70,7 @@ module Hecks
|
|
|
63
70
|
check_redirect(row, path)
|
|
64
71
|
check_navigation(row, path)
|
|
65
72
|
check_indexing(row, path)
|
|
73
|
+
check_beneath_prefix(row, path)
|
|
66
74
|
return unless row.preview == "draft" && row.source == "none"
|
|
67
75
|
|
|
68
76
|
problem(path, "previews as a draft but has source none; a draft preview needs a global: or collection: source")
|
|
@@ -90,13 +98,51 @@ module Hecks
|
|
|
90
98
|
slots = self.class.navigation(row)
|
|
91
99
|
return if slots.empty?
|
|
92
100
|
|
|
93
|
-
|
|
94
|
-
|
|
101
|
+
unless row.verbs.include?("GET")
|
|
102
|
+
problem(path, "sits in the navigation but answers #{row.verbs.join(',')}; a link is a GET")
|
|
103
|
+
end
|
|
95
104
|
problem(path, "sits in the navigation and has a parameter") if path.match?(/[:*]/)
|
|
96
105
|
problem(path, "sits in the navigation and has no label") if row.label.to_s.empty?
|
|
106
|
+
check_heading(row, path)
|
|
97
107
|
check_menu_audience(row, path, slots)
|
|
98
108
|
end
|
|
99
109
|
|
|
110
|
+
def check_heading(item, path)
|
|
111
|
+
return unless item.mobile_heading
|
|
112
|
+
return problem(path, "has a mobile_heading but no mobile_order") if item.mobile_order.nil?
|
|
113
|
+
|
|
114
|
+
problem(path, "has an empty mobile_heading") if item.mobile_heading.strip.empty?
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def check_link(link)
|
|
118
|
+
path = "NavLink #{link.path}"
|
|
119
|
+
slots = self.class.navigation(link)
|
|
120
|
+
problem(path, "sits in no navigation; give it nav_order, mobile_order, footer_column or admin_key") if slots.empty?
|
|
121
|
+
problem(path, "has a parameter; a link is to one page") if link.path.to_s.match?(/[:*]/)
|
|
122
|
+
problem(path, "has no label") if link.label.to_s.empty?
|
|
123
|
+
if link.fragment && !link.fragment.match?(FRAGMENT)
|
|
124
|
+
problem(path, "has fragment #{link.fragment.inspect}; a fragment is an element id, written without #")
|
|
125
|
+
end
|
|
126
|
+
check_heading(link, path)
|
|
127
|
+
check_menu_audience(link, path, slots)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# A public route under a prefix that is not public (the sign-in page beside the admin
|
|
131
|
+
# pages) inherits nothing from the prefix, so the row has to say what it is: its cache
|
|
132
|
+
# class is declared, not derived from its auth.
|
|
133
|
+
def check_beneath_prefix(row, path)
|
|
134
|
+
return if row.auth != "public" || row.explicit_cache || row.path.nil?
|
|
135
|
+
|
|
136
|
+
pattern = Edge::Pattern.edge(row.path)
|
|
137
|
+
prefix = @rows.find do |other|
|
|
138
|
+
other.auth != "public" && other.path && Edge::Pattern.strictly_covers?(Edge::Pattern.edge(other.path), pattern)
|
|
139
|
+
end
|
|
140
|
+
return unless prefix
|
|
141
|
+
|
|
142
|
+
problem(path, "is public but sits beneath #{prefix.path}, which is #{prefix.auth}; " \
|
|
143
|
+
"name its cache class (cache: \"no_store\" for a sign-in page) to say that is meant")
|
|
144
|
+
end
|
|
145
|
+
|
|
100
146
|
def check_menu_audience(row, path, slots)
|
|
101
147
|
public_slots = slots & PUBLIC_MENUS
|
|
102
148
|
if row.auth != "public" && public_slots.any?
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hecks
|
|
4
|
+
module Projections
|
|
5
|
+
module Site
|
|
6
|
+
class Table
|
|
7
|
+
# One extra navigation entry that points at a route the table declares, optionally at a
|
|
8
|
+
# fragment of its page. `off` and `switch` are copied from the route it points at.
|
|
9
|
+
Link = Struct.new(:path, :fragment, :label, :nav_group, :nav_order, :mobile_order, :mobile_heading,
|
|
10
|
+
:footer_column, :footer_order, :admin_key, :admin_order, :off, :switch, :auth,
|
|
11
|
+
keyword_init: true)
|
|
12
|
+
|
|
13
|
+
# Turns a declared `NavLink` member into a `Link`: checks its fields and their types, and
|
|
14
|
+
# copies the facts it shares with the route it points at.
|
|
15
|
+
class LinkBuilder
|
|
16
|
+
# The fields a link may carry, with the Ruby class each takes.
|
|
17
|
+
FIELDS = { path: String, fragment: String, label: String, nav_group: String, nav_order: Integer,
|
|
18
|
+
mobile_order: Integer, mobile_heading: String, footer_column: String,
|
|
19
|
+
footer_order: Integer, admin_key: String, admin_order: Integer }.freeze
|
|
20
|
+
|
|
21
|
+
# @param rows [Array<Row>] the table's rows, which a link points at
|
|
22
|
+
# @param problems [Array<String>] collects each problem found, one line each
|
|
23
|
+
def initialize(rows, problems)
|
|
24
|
+
@rows = rows
|
|
25
|
+
@problems = problems
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param member [Hash{Symbol => Object}] the declared fields of one link
|
|
29
|
+
# @param index [Integer] the link's position, for a message about a link with no path
|
|
30
|
+
# @return [Link] the link; its problems are in `problems`
|
|
31
|
+
def call(member, index)
|
|
32
|
+
label = "NavLink #{member[:path] || "row #{index + 1}"}"
|
|
33
|
+
fields = checked_fields(member, label)
|
|
34
|
+
target = @rows.find { |row| row.path == fields[:path] && row.verbs.include?("GET") }
|
|
35
|
+
problem(label, "points at no route that answers GET; declare the route first") unless target
|
|
36
|
+
link = Link.new(**fields, off: target ? target.off : false, switch: target ? target.switch : "",
|
|
37
|
+
auth: target ? target.auth : "public")
|
|
38
|
+
link.footer_order ||= 0 if link.footer_column
|
|
39
|
+
link.admin_order ||= 0 if link.admin_key
|
|
40
|
+
link
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def checked_fields(member, label)
|
|
46
|
+
member.each_key do |key|
|
|
47
|
+
problem(label, "has no field #{key}; fields are #{FIELDS.keys.join(', ')}") unless FIELDS.key?(key)
|
|
48
|
+
end
|
|
49
|
+
known = member.slice(*FIELDS.keys)
|
|
50
|
+
known.each do |key, value|
|
|
51
|
+
next if value.is_a?(FIELDS.fetch(key))
|
|
52
|
+
|
|
53
|
+
problem(label, "has #{key} #{value.inspect}; #{key} is a #{FIELDS.fetch(key)}")
|
|
54
|
+
end
|
|
55
|
+
problem(label, "needs a path") unless known[:path]
|
|
56
|
+
known
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def problem(label, text) = @problems << "#{label} #{text}"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -8,7 +8,8 @@ module Hecks
|
|
|
8
8
|
Row = Struct.new(:path, :kind, :render, :auth, :verbs, :cache, :origin, :source,
|
|
9
9
|
:indexable, :switch, :off, :preview, :label, :seo, :redirect_to, :aliases,
|
|
10
10
|
:nav_group, :nav_order, :mobile_order, :footer_column, :footer_order,
|
|
11
|
-
:admin_key, :admin_order, :compress, :alb_rule, :cdn,
|
|
11
|
+
:admin_key, :admin_order, :compress, :alb_rule, :cdn,
|
|
12
|
+
:seo_title, :edge_verbs, :mobile_heading, :explicit_cache, keyword_init: true)
|
|
12
13
|
|
|
13
14
|
# Turns a declared member into a `Row`: checks its fields and their types, fills the
|
|
14
15
|
# defaults, and checks each closed-set value against the Site chapter's vocabulary.
|
|
@@ -19,7 +20,8 @@ module Hecks
|
|
|
19
20
|
off: :bool, preview: String, label: String, seo: String, redirect_to: String,
|
|
20
21
|
aliases: String, nav_group: String, nav_order: Integer, mobile_order: Integer,
|
|
21
22
|
footer_column: String, footer_order: Integer, admin_key: String,
|
|
22
|
-
admin_order: Integer, compress: :bool, alb_rule: String, cdn: :bool
|
|
23
|
+
admin_order: Integer, compress: :bool, alb_rule: String, cdn: :bool,
|
|
24
|
+
seo_title: String, edge_methods: String, mobile_heading: String }.freeze
|
|
23
25
|
|
|
24
26
|
# The value objects of the Site chapter's `Route` aggregate whose members are the closed
|
|
25
27
|
# sets, by the row field each constrains.
|
|
@@ -50,7 +52,9 @@ module Hecks
|
|
|
50
52
|
label = path || label
|
|
51
53
|
check_source(label, source)
|
|
52
54
|
verbs = fields.delete(:methods)
|
|
53
|
-
|
|
55
|
+
edge_verbs = fields.delete(:edge_methods)
|
|
56
|
+
row = Row.new(**fields, verbs: verbs, edge_verbs: edge_verbs, path: path, source: source,
|
|
57
|
+
explicit_cache: !fields[:cache].nil?,
|
|
54
58
|
kind: fields[:kind] || default_kind(source), auth: fields[:auth] || "public",
|
|
55
59
|
origin: fields[:origin] || default_origin(source))
|
|
56
60
|
fill_defaults(row)
|
|
@@ -94,6 +98,7 @@ module Hecks
|
|
|
94
98
|
row.cdn = true if row.cdn.nil?
|
|
95
99
|
row.render ||= row.kind == "page" ? "prerender" : "ssr"
|
|
96
100
|
row.verbs = list(row.verbs || (row.source.start_with?("command:") ? "POST" : "GET"))
|
|
101
|
+
row.edge_verbs = row.edge_verbs.nil? ? row.verbs : list(row.edge_verbs)
|
|
97
102
|
row.aliases = list(row.aliases)
|
|
98
103
|
row.cache ||= default_cache(row)
|
|
99
104
|
row.indexable = indexable_by_default?(row) if row.indexable.nil?
|
|
@@ -123,11 +128,21 @@ module Hecks
|
|
|
123
128
|
|
|
124
129
|
problem(label, "has #{field} #{row[field].inspect}; #{field} is one of #{@vocabulary.fetch(field).join(', ')}")
|
|
125
130
|
end
|
|
126
|
-
row
|
|
127
|
-
|
|
131
|
+
check_verbs(row, label)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def check_verbs(row, label)
|
|
135
|
+
{ "method" => row.verbs, "edge method" => row.edge_verbs }.each do |what, verbs|
|
|
136
|
+
verbs.each do |verb|
|
|
137
|
+
next if @vocabulary.fetch(:http_method).include?(verb)
|
|
128
138
|
|
|
129
|
-
|
|
139
|
+
problem(label, "has #{what} #{verb.inspect}; methods are #{@vocabulary.fetch(:http_method).join(', ')}")
|
|
140
|
+
end
|
|
130
141
|
end
|
|
142
|
+
return if (row.verbs - row.edge_verbs).empty?
|
|
143
|
+
|
|
144
|
+
problem(label, "has edge_methods #{row.edge_verbs.join(',')}, which leave out its methods " \
|
|
145
|
+
"#{(row.verbs - row.edge_verbs).join(',')}; the edge must allow every verb the route answers")
|
|
131
146
|
end
|
|
132
147
|
|
|
133
148
|
def check_source(label, source)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "table/row_builder"
|
|
4
|
+
require_relative "table/link_builder"
|
|
4
5
|
require_relative "table/checks"
|
|
5
6
|
|
|
6
7
|
module Hecks
|
|
@@ -21,9 +22,15 @@ module Hecks
|
|
|
21
22
|
# The name of the value object a client declares its rows in.
|
|
22
23
|
ROW_OBJECT = "Route"
|
|
23
24
|
|
|
25
|
+
# The name of the value object a client declares its extra navigation entries in.
|
|
26
|
+
LINK_OBJECT = "NavLink"
|
|
27
|
+
|
|
24
28
|
# @return [Array<Row>] the rows, in the order the client declared them
|
|
25
29
|
attr_reader :rows
|
|
26
30
|
|
|
31
|
+
# @return [Array<Link>] the extra navigation entries, in the order the client declared them
|
|
32
|
+
attr_reader :links
|
|
33
|
+
|
|
27
34
|
# Finds the one chapter of a project that declares the route table.
|
|
28
35
|
#
|
|
29
36
|
# @param registry [Runtime::Registry] the registry the project booted into
|
|
@@ -47,7 +54,8 @@ module Hecks
|
|
|
47
54
|
# @return [Table] the checked table
|
|
48
55
|
# @raise [Invalid] when the Site chapter is not attached or a row is refused
|
|
49
56
|
def self.read(chapter, registry:)
|
|
50
|
-
new(rows_of(chapter), vocabulary: vocabulary(registry), registry: registry
|
|
57
|
+
new(rows_of(chapter), vocabulary: vocabulary(registry), registry: registry,
|
|
58
|
+
links: rows_of(chapter, LINK_OBJECT))
|
|
51
59
|
end
|
|
52
60
|
|
|
53
61
|
# @param registry [Runtime::Registry] a registry holding the Site chapter
|
|
@@ -76,20 +84,23 @@ module Hecks
|
|
|
76
84
|
# @param vocabulary [Hash{Symbol => Array<String>}] the closed sets
|
|
77
85
|
# @param registry [Runtime::Registry, nil] where a `command:` or `query:` source is looked
|
|
78
86
|
# up; nil skips that check
|
|
87
|
+
# @param links [Array<Hash{Symbol => Object}>] the declared `NavLink` rows
|
|
79
88
|
# @raise [Invalid] when a row is refused
|
|
80
|
-
def initialize(members, vocabulary:, registry: nil)
|
|
89
|
+
def initialize(members, vocabulary:, registry: nil, links: [])
|
|
81
90
|
problems = []
|
|
82
91
|
builder = RowBuilder.new(vocabulary: vocabulary, registry: registry, problems: problems)
|
|
83
92
|
@rows = members.each_with_index.map { |member, index| builder.call(member, index) }
|
|
84
|
-
|
|
93
|
+
link_builder = LinkBuilder.new(@rows, problems)
|
|
94
|
+
@links = links.each_with_index.map { |member, index| link_builder.call(member, index) }
|
|
95
|
+
Checks.new(@rows, problems, links: @links).call
|
|
85
96
|
return if problems.empty?
|
|
86
97
|
|
|
87
98
|
raise Invalid, "the route table is refused:\n#{problems.map { |line| " - #{line}" }.join("\n")}"
|
|
88
99
|
end
|
|
89
100
|
|
|
90
|
-
# The navigation slots a row sits in.
|
|
101
|
+
# The navigation slots a row or link sits in.
|
|
91
102
|
#
|
|
92
|
-
# @param row [Row] a row
|
|
103
|
+
# @param row [Row, Link] a row or link
|
|
93
104
|
# @return [Array<String>] some of `desktop`, `mobile`, `footer`, `admin`
|
|
94
105
|
def navigation(row) = Checks.navigation(row)
|
|
95
106
|
end
|
|
@@ -30,7 +30,8 @@ module Hecks
|
|
|
30
30
|
# @return [String] the input as JSON, ready for the binary's stdin
|
|
31
31
|
def json(domain_path, steps) = JSON.generate(build(domain_path, steps))
|
|
32
32
|
|
|
33
|
-
# Each command's declared defaults, read from the domain's
|
|
33
|
+
# Each command's declared defaults, entity commands included, read from the domain's
|
|
34
|
+
# generated IR. An entity's commands sit under `Domain::Aggregate.Entity[.Entity].Command`.
|
|
34
35
|
#
|
|
35
36
|
# @param domain_path [String] the domain's directory
|
|
36
37
|
# @return [Hash{String => Hash{String => Object}}] empty when the IR is absent or has none
|
|
@@ -38,14 +39,25 @@ module Hecks
|
|
|
38
39
|
ir = generated_ir(domain_path) or return {}
|
|
39
40
|
domain = ir["name"].to_s
|
|
40
41
|
Array(ir["aggregates"]).each_with_object({}) do |aggregate, table|
|
|
41
|
-
|
|
42
|
-
held = Array(command["attributes"]).reject { |attribute| attribute["default"].nil? }
|
|
43
|
-
.to_h { |attribute| [attribute["name"], attribute["default"]] }
|
|
44
|
-
table["#{domain}::#{aggregate['name']}.#{command['name']}"] = held unless held.empty?
|
|
45
|
-
end
|
|
42
|
+
collect_defaults(aggregate, "#{domain}::#{aggregate['name']}", table)
|
|
46
43
|
end
|
|
47
44
|
end
|
|
48
45
|
|
|
46
|
+
# Adds the defaults of `node`'s commands, then of each entity nested in it, under `prefix`.
|
|
47
|
+
#
|
|
48
|
+
# @param node [Hash] an aggregate or entity of the IR
|
|
49
|
+
# @param prefix [String] the verb path down to `node`
|
|
50
|
+
# @param table [Hash] filled in place
|
|
51
|
+
# @return [void]
|
|
52
|
+
def collect_defaults(node, prefix, table)
|
|
53
|
+
Array(node["commands"]).each do |command|
|
|
54
|
+
held = Array(command["attributes"]).reject { |attribute| attribute["default"].nil? }
|
|
55
|
+
.to_h { |attribute| [attribute["name"], attribute["default"]] }
|
|
56
|
+
table["#{prefix}.#{command['name']}"] = held unless held.empty?
|
|
57
|
+
end
|
|
58
|
+
Array(node["entities"]).each { |entity| collect_defaults(entity, "#{prefix}.#{entity['name']}", table) }
|
|
59
|
+
end
|
|
60
|
+
|
|
49
61
|
# @param domain_path [String] the domain's directory
|
|
50
62
|
# @return [Hash, nil] the parsed generated IR, or nil when the domain has none
|
|
51
63
|
def generated_ir(domain_path)
|
|
@@ -17,9 +17,8 @@ module Hecks
|
|
|
17
17
|
# ProjectRust.call(["path/to/domain"])
|
|
18
18
|
#
|
|
19
19
|
# `hecks-codegen` is the generator (ADR 0086): this class builds the IR from the live registry
|
|
20
|
-
# and `CodegenRun` runs the binary on it.
|
|
21
|
-
#
|
|
22
|
-
# `rust/project_rust_pipeline.rb` instead, with no Ruby load of the domain.
|
|
20
|
+
# and `CodegenRun` runs the binary on it. The Ruby-free path is `hecks-build`, which feeds the
|
|
21
|
+
# same binary from `hecks-parse`.
|
|
23
22
|
#
|
|
24
23
|
# The era plugin is required unconditionally so the generator works for any domain, lineage
|
|
25
24
|
# capable or not; `pg` stays lazy inside it, so nothing here opens a database connection.
|
|
@@ -46,13 +45,7 @@ module Hecks
|
|
|
46
45
|
# @return [Integer] the exit status
|
|
47
46
|
def call
|
|
48
47
|
validate_name!
|
|
49
|
-
|
|
50
|
-
require_relative "../../../rust/project"
|
|
51
|
-
require_relative "../../../rust/project_rust_pipeline"
|
|
52
|
-
RustProjectPipeline.call(@domain)
|
|
53
|
-
else
|
|
54
|
-
generate
|
|
55
|
-
end
|
|
48
|
+
generate
|
|
56
49
|
0
|
|
57
50
|
end
|
|
58
51
|
|
|
@@ -78,18 +71,11 @@ module Hecks
|
|
|
78
71
|
FileUtils.mkdir_p(@out_root)
|
|
79
72
|
build_target_ir
|
|
80
73
|
FileUtils.rm_rf(File.join(@out_root, "active"))
|
|
81
|
-
|
|
74
|
+
generate_with_codegen
|
|
82
75
|
write_root_mod
|
|
83
76
|
sync_cargo_features
|
|
84
77
|
end
|
|
85
78
|
|
|
86
|
-
# The rollback path (ADR 0086): the Ruby generator in `rust/project`, until it is deleted.
|
|
87
|
-
def generate_with_ruby
|
|
88
|
-
require_relative "../../../rust/project"
|
|
89
|
-
write_meta
|
|
90
|
-
write_target
|
|
91
|
-
end
|
|
92
|
-
|
|
93
79
|
# `hecks-codegen` is the generator; this process only builds the IR it reads from the live
|
|
94
80
|
# registry, so a domain's persistence, seams, translations and source text reach `ir.json`.
|
|
95
81
|
def generate_with_codegen
|
|
@@ -105,8 +91,8 @@ module Hecks
|
|
|
105
91
|
).call
|
|
106
92
|
end
|
|
107
93
|
|
|
108
|
-
# The IR as
|
|
109
|
-
#
|
|
94
|
+
# The IR as the generator reads it: string-keyed through JSON, with the append-optional
|
|
95
|
+
# fields marked, since `ir.json` records them.
|
|
110
96
|
def prepared_ir(tree, shaped: true)
|
|
111
97
|
AppendOptionals.mark(shaped ? json_shaped(tree) : tree)
|
|
112
98
|
end
|
|
@@ -186,78 +172,6 @@ module Hecks
|
|
|
186
172
|
)
|
|
187
173
|
end
|
|
188
174
|
|
|
189
|
-
# The self-hosted language goes through the same codegen as the target, read from the
|
|
190
|
-
# grammar registry `MetaValidator` boots with.
|
|
191
|
-
def write_meta
|
|
192
|
-
meta_ir = json_shaped(
|
|
193
|
-
Hecks::Projector::Exporter.call(Hecks::Bluebook::MetaValidator.grammar_registry).fetch("Bluebook")
|
|
194
|
-
)
|
|
195
|
-
dir = File.join(@out_root, "meta")
|
|
196
|
-
# Tracked per directory: several domains coexist on disk, and `pop_and_prune` deletes only
|
|
197
|
-
# the files this run never touched. `active` is a set of feature re-exports in the root
|
|
198
|
-
# `mod.rs`, never a directory.
|
|
199
|
-
WriteIfChanged.push_directory(dir)
|
|
200
|
-
result = RustProjection::DomainGenerator.call(
|
|
201
|
-
meta_ir, "the self-hosted language (lib/hecks/language/bluebook)", dir, "meta"
|
|
202
|
-
)
|
|
203
|
-
write_merged(File.join(dir, "merged.rs"), result[:aggregates], result[:queries], result[:read_models],
|
|
204
|
-
policies: RustProjection::Projector.emit_policy_table(meta_ir[:name], meta_ir[:policies],
|
|
205
|
-
meta_ir[:aggregates]),
|
|
206
|
-
cross: RustProjection::Projector.emit_cross_domain_policy_table(meta_ir[:name],
|
|
207
|
-
meta_ir[:policies]),
|
|
208
|
-
managers: meta_ir[:process_managers],
|
|
209
|
-
keys: [[meta_ir[:name], result[:aggregates].map { |a| a[:name] }]])
|
|
210
|
-
append_merged_mod(File.join(dir, "mod.rs"))
|
|
211
|
-
WriteIfChanged.pop_and_prune(dir)
|
|
212
|
-
end
|
|
213
|
-
|
|
214
|
-
def write_target
|
|
215
|
-
dir = File.join(@out_root, @mod_name)
|
|
216
|
-
WriteIfChanged.push_directory(dir)
|
|
217
|
-
result = RustProjection::DomainGenerator.call(@ir, @domain, dir, @mod_name)
|
|
218
|
-
chapters = write_chapters
|
|
219
|
-
aggregates = result[:aggregates] + chapters[:aggregates]
|
|
220
|
-
write_merged(File.join(dir, "merged.rs"), aggregates, result[:queries] + chapters[:queries],
|
|
221
|
-
result[:read_models] + chapters[:read_models],
|
|
222
|
-
policies: RustProjection::Projector.emit_merged_policy_table(chapters[:policy_sources]),
|
|
223
|
-
cross: RustProjection::Projector.emit_merged_cross_domain_policy_table(chapters[:policy_sources]),
|
|
224
|
-
managers: @ir[:process_managers] + chapters[:process_managers],
|
|
225
|
-
keys: reference_keys(result[:aggregates], chapters))
|
|
226
|
-
append_merged_mod(File.join(dir, "mod.rs"))
|
|
227
|
-
WriteIfChanged.pop_and_prune(dir)
|
|
228
|
-
end
|
|
229
|
-
|
|
230
|
-
# Every other bluebook the target's `attaches` calls pulled into the registry, each
|
|
231
|
-
# generated into its own module. A framework chapter is a function of its bluebook alone, so
|
|
232
|
-
# regenerating one is safe whichever domain last touched it.
|
|
233
|
-
def write_chapters
|
|
234
|
-
totals = { aggregates: [], queries: [], read_models: [], process_managers: [],
|
|
235
|
-
policy_sources: [{ domain_name: @domain_name, policies: @ir[:policies],
|
|
236
|
-
aggregates: @ir[:aggregates] }],
|
|
237
|
-
mod_names: { @mod_name => @domain_name } }
|
|
238
|
-
vendored = @registry.hecksagons.values.flat_map(&:vendored_packages)
|
|
239
|
-
(@registry.bluebooks.keys - [@domain_name]).each { |chapter| write_chapter(chapter, vendored, totals) }
|
|
240
|
-
totals
|
|
241
|
-
end
|
|
242
|
-
|
|
243
|
-
def write_chapter(chapter_name, vendored, totals)
|
|
244
|
-
mod_name = chapter_name.downcase
|
|
245
|
-
totals[:mod_names][mod_name] = chapter_name
|
|
246
|
-
chapter_ir = json_shaped(Hecks::Projector::Exporter.call(@registry).fetch(chapter_name))
|
|
247
|
-
result = nil
|
|
248
|
-
WriteIfChanged.track_directory(File.join(@out_root, mod_name)) do
|
|
249
|
-
result = RustProjection::DomainGenerator.call(chapter_ir, "#{@domain} (#{attachment(chapter_name, vendored)})",
|
|
250
|
-
File.join(@out_root, mod_name), mod_name,
|
|
251
|
-
merged_module: false)
|
|
252
|
-
end
|
|
253
|
-
totals[:aggregates].concat(result[:aggregates])
|
|
254
|
-
totals[:queries].concat(result[:queries])
|
|
255
|
-
totals[:read_models].concat(result[:read_models])
|
|
256
|
-
totals[:process_managers].concat(chapter_ir[:process_managers])
|
|
257
|
-
totals[:policy_sources] << { domain_name: chapter_name, policies: chapter_ir[:policies],
|
|
258
|
-
aggregates: chapter_ir[:aggregates] }
|
|
259
|
-
end
|
|
260
|
-
|
|
261
175
|
# How the target attached a chapter, across every hecksagon the registry loaded: a chapter a
|
|
262
176
|
# sibling hecksagon vendors in is labelled as vendored.
|
|
263
177
|
def attachment(chapter_name, vendored)
|
|
@@ -265,46 +179,6 @@ module Hecks
|
|
|
265
179
|
name ? "attaches #{name.inspect}, from: :vendor" : "attaches #{chapter_name.inspect}"
|
|
266
180
|
end
|
|
267
181
|
|
|
268
|
-
def reference_keys(target_aggregates, chapters)
|
|
269
|
-
chapters[:mod_names].map do |mod_name, chapter_name|
|
|
270
|
-
own = mod_name == @mod_name ? target_aggregates : chapters[:aggregates].select { |a| a[:chapter_mod] == mod_name }
|
|
271
|
-
[chapter_name, own.map { |a| a[:name] }]
|
|
272
|
-
end
|
|
273
|
-
end
|
|
274
|
-
|
|
275
|
-
# Writes a module's `merged.rs`: the registry and every dispatch table over its aggregates.
|
|
276
|
-
def write_merged(path, aggregates, queries, read_models, policies:, cross:, managers:, keys:)
|
|
277
|
-
projector = RustProjection::Projector
|
|
278
|
-
wrote = WriteIfChanged.block(path) do |file|
|
|
279
|
-
[projector.emit_registry(aggregates), projector.emit_reference_lookup(aggregates), policies, cross,
|
|
280
|
-
projector.emit_process_manager_table(managers), projector.emit_reference_key_table(keys),
|
|
281
|
-
projector.emit_creates_table(aggregates), projector.emit_identity_head_table(aggregates),
|
|
282
|
-
projector.emit_entity_identity_head_table(aggregates),
|
|
283
|
-
projector.emit_command_attributes_table(aggregates), projector.emit_query_table(queries),
|
|
284
|
-
projector.emit_query_arg_check_table(queries)].each do |table|
|
|
285
|
-
file.puts table
|
|
286
|
-
file.puts
|
|
287
|
-
end
|
|
288
|
-
# A read model's `group_by` function is written before the table names it.
|
|
289
|
-
read_models.each do |model|
|
|
290
|
-
next unless model[:group_by_fn_body]
|
|
291
|
-
|
|
292
|
-
file.puts model[:group_by_fn_body]
|
|
293
|
-
file.puts
|
|
294
|
-
end
|
|
295
|
-
file.puts projector.emit_read_model_table(read_models)
|
|
296
|
-
end
|
|
297
|
-
puts(wrote ? "wrote #{path}" : "#{path} unchanged")
|
|
298
|
-
end
|
|
299
|
-
|
|
300
|
-
# Appends the `pub mod merged;` line once; appending it again would grow the file and bump
|
|
301
|
-
# its mtime on every run.
|
|
302
|
-
def append_merged_mod(path)
|
|
303
|
-
return if File.exist?(path) && File.read(path).include?("pub mod merged;")
|
|
304
|
-
|
|
305
|
-
File.open(path, "a") { |file| file.puts "pub mod merged;" }
|
|
306
|
-
end
|
|
307
|
-
|
|
308
182
|
# `src/generated/mod.rs`: every domain ever generated, scanned off disk. A directory is a
|
|
309
183
|
# selectable domain exactly when it carries its own `merged.rs`; a framework chapter has
|
|
310
184
|
# none and is always compiled in.
|
|
@@ -95,12 +95,14 @@ Hecks.bluebook "Site" do
|
|
|
95
95
|
aggregate "SiteProjection" do
|
|
96
96
|
description "One projection of a site's route table into its routes.ts, or the check that the file on disk is current."
|
|
97
97
|
|
|
98
|
-
attribute :domain,
|
|
99
|
-
attribute :run,
|
|
100
|
-
attribute :out,
|
|
101
|
-
attribute :
|
|
102
|
-
attribute :
|
|
103
|
-
attribute :
|
|
98
|
+
attribute :domain, DomainPath
|
|
99
|
+
attribute :run, RunKey
|
|
100
|
+
attribute :out, Location, optional: true
|
|
101
|
+
attribute :template, Location, optional: true
|
|
102
|
+
attribute :extension, Extension, optional: true
|
|
103
|
+
attribute :check, Switch, optional: true
|
|
104
|
+
attribute :output, Report, optional: true
|
|
105
|
+
attribute :refusal, Report, optional: true
|
|
104
106
|
identified_by :run
|
|
105
107
|
|
|
106
108
|
value_object "RunKey" do
|
|
@@ -116,11 +118,18 @@ Hecks.bluebook "Site" do
|
|
|
116
118
|
invariant("a project is named by its path") { !value.to_s.empty? }
|
|
117
119
|
end
|
|
118
120
|
|
|
119
|
-
#
|
|
121
|
+
# A directory or file anywhere on disk, relative to where the command runs: where routes.ts is
|
|
122
|
+
# written when not in the project's generated/ directory, or the template to rewrite in place.
|
|
120
123
|
value_object "Location" do
|
|
121
124
|
attribute :value, String, pattern: '[^\t\n\r]'
|
|
122
125
|
end
|
|
123
126
|
|
|
127
|
+
# The file extension of the generated module: `ts` (routes.ts), or `mts` (routes.mts), which
|
|
128
|
+
# Node loads as an ES module even in a package whose "type" is "commonjs".
|
|
129
|
+
value_object "Extension" do
|
|
130
|
+
attribute :value, String, one_of: ["ts", "mts"]
|
|
131
|
+
end
|
|
132
|
+
|
|
124
133
|
value_object "Switch" do
|
|
125
134
|
attribute :value, Boolean
|
|
126
135
|
end
|
|
@@ -134,19 +143,24 @@ Hecks.bluebook "Site" do
|
|
|
134
143
|
transition "Fault" => "faulted", from: "requested"
|
|
135
144
|
end
|
|
136
145
|
|
|
137
|
-
# `hecks site site_projection.project_site <project> [out=<dir>] [--check]`.
|
|
138
|
-
# routes.ts that differs from the table is faulted, naming the
|
|
146
|
+
# `hecks site site_projection.project_site <project> [out=<dir>] [template=<file>] [extension=ts|mts] [--check]`.
|
|
147
|
+
# With `check` nothing is written: a routes.ts that differs from the table is faulted, naming the
|
|
148
|
+
# file, so a CI job fails on drift.
|
|
139
149
|
command "ProjectSite" do
|
|
140
150
|
goal "Project a site's route table into routes.ts, or with check only compare it with the file on disk"
|
|
141
151
|
|
|
142
|
-
attribute :domain,
|
|
143
|
-
attribute :run,
|
|
144
|
-
attribute :out,
|
|
145
|
-
attribute :
|
|
152
|
+
attribute :domain, DomainPath
|
|
153
|
+
attribute :run, RunKey
|
|
154
|
+
attribute :out, Location, optional: true
|
|
155
|
+
attribute :template, Location, optional: true
|
|
156
|
+
attribute :extension, Extension, optional: true
|
|
157
|
+
attribute :check, Switch, optional: true
|
|
146
158
|
|
|
147
159
|
sets :domain
|
|
148
160
|
sets :run
|
|
149
161
|
sets :out
|
|
162
|
+
sets :template
|
|
163
|
+
sets :extension
|
|
150
164
|
sets :check
|
|
151
165
|
|
|
152
166
|
emits "SiteProjectionRequested"
|
data/lib/hecks/storehouse.rb
CHANGED
|
@@ -230,10 +230,44 @@ module Hecks
|
|
|
230
230
|
result = runtime.dispatch_flat(spec[:command], envelope)
|
|
231
231
|
ok(summary: summary,
|
|
232
232
|
id: result.id,
|
|
233
|
-
state:
|
|
233
|
+
state: settled_state(runtime, spec, result),
|
|
234
234
|
events: result.events.map { |event| { name: event.name, payload: Doors::JsonDoor.materialize(event.payload) } })
|
|
235
235
|
end
|
|
236
236
|
|
|
237
|
+
# The record as its repository holds it once every reaction has run, which is the state the
|
|
238
|
+
# dispatch resulted in; the handle's own state is the record as the command left it, so a run
|
|
239
|
+
# record that a reaction completes would otherwise read as `requested`. Falls back to the
|
|
240
|
+
# handle's state for a command that belongs to no top-level aggregate or whose record cannot be
|
|
241
|
+
# read.
|
|
242
|
+
# :nodoc:
|
|
243
|
+
def settled_state(runtime, spec, result)
|
|
244
|
+
return if result.state.nil?
|
|
245
|
+
|
|
246
|
+
bluebook = bluebook_for(runtime)
|
|
247
|
+
head = spec[:command].split("::", 2).last
|
|
248
|
+
aggregate = bluebook.aggregates.find { |candidate| candidate.hecks_name == head.split(".").first } if
|
|
249
|
+
head.count(".") == 1
|
|
250
|
+
record = aggregate && runtime.registry.repository(bluebook.name, aggregate)&.find(result.id)&.to_h
|
|
251
|
+
Doors::JsonDoor.materialize(record || result.state)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# What a caller needs to call each of the named commands: its qualified name, the role it
|
|
255
|
+
# declares, what it does, and its argument names, a trailing `*` marking a required one.
|
|
256
|
+
#
|
|
257
|
+
# @param runtime [Runtime::Dispatcher] the booted domain
|
|
258
|
+
# @param names [Array<String>] short or qualified command names
|
|
259
|
+
# @return [Array<String>] one line per name that resolves, in order
|
|
260
|
+
def command_guide(runtime, names)
|
|
261
|
+
cli = Projector.call(:cli, bluebook: bluebook_for(runtime), options: { program: "mcp" })
|
|
262
|
+
names.filter_map do |name|
|
|
263
|
+
spec = resolve!(cli, name.to_s, asking: false)
|
|
264
|
+
arguments = spec[:arguments].map { |arg| arg[:path].split(".").first + (arg[:required] ? "*" : "") }
|
|
265
|
+
"#{name} (role #{spec[:role] || 'none'}): #{spec[:summary]}. Arguments: #{arguments.uniq.join(', ')}"
|
|
266
|
+
rescue Runtime::NotFound
|
|
267
|
+
nil
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
237
271
|
# :nodoc:
|
|
238
272
|
def dry_run_outcome(runtime, spec, envelope, summary:)
|
|
239
273
|
flat = flatten_legacy(envelope, spec[:receiver], spec[:legacy_receiver])
|
|
@@ -946,10 +946,12 @@ module Hecks
|
|
|
946
946
|
$CHILD_STATUS.success? ? out : nil
|
|
947
947
|
end
|
|
948
948
|
|
|
949
|
-
# `.rb` files, plus extensionless scripts with a Ruby shebang.
|
|
949
|
+
# `.rb` files, plus extensionless scripts with a Ruby shebang. The first line is read as
|
|
950
|
+
# bytes, so an extensionless binary (a compiled test fixture's output) is passed over
|
|
951
|
+
# rather than refused for not being UTF-8.
|
|
950
952
|
def ruby_files(dir)
|
|
951
953
|
scripts = Dir[File.join(dir, "**", "*")].select do |path|
|
|
952
|
-
File.file?(path) && File.extname(path).empty? && File.open(path, &:gets).to_s.match?(/\A#!.*ruby/)
|
|
954
|
+
File.file?(path) && File.extname(path).empty? && File.open(path, "rb", &:gets).to_s.match?(/\A#!.*ruby/n)
|
|
953
955
|
end
|
|
954
956
|
Dir[File.join(dir, "**", "*.rb")] + scripts
|
|
955
957
|
end
|