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.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +3 -3
  3. data/lib/hecks/cli/check_era.rb +3 -39
  4. data/lib/hecks/deploy/bluebook/deploy.bluebook +105 -0
  5. data/lib/hecks/deploy/bluebook/deploy.hecksagon +8 -0
  6. data/lib/hecks/deploy/oidc.json +15 -0
  7. data/lib/hecks/doors/mcp_door.rb +16 -2
  8. data/lib/hecks/doors/mcp_door_scope.rb +43 -4
  9. data/lib/hecks/doors/mcp_guide_cache.rb +75 -0
  10. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +17 -1
  11. data/lib/hecks/hecks/adapters/codebase/test_runner.rb +10 -2
  12. data/lib/hecks/hecks/adapters/cost_explorer.adapter +7 -0
  13. data/lib/hecks/hecks/adapters/cost_explorer.rb +102 -0
  14. data/lib/hecks/hecks/adapters/rust_workspace.rb +1 -1
  15. data/lib/hecks/hecks/adapters/site_toolchain.rb +21 -11
  16. data/lib/hecks/hecks/hecks.hecksagon +8 -0
  17. data/lib/hecks/language/bluebook/vocabulary.bluebook +7 -7
  18. data/lib/hecks/language/hecksagon/hecksagon.bluebook +1 -1
  19. data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +3 -3
  20. data/lib/hecks/projections/deploy/box/settings.rb +23 -2
  21. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +3 -1
  22. data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +17 -0
  23. data/lib/hecks/projections/deploy/box.rb +13 -2
  24. data/lib/hecks/projections/site/edge/behaviours.rb +5 -3
  25. data/lib/hecks/projections/site/edge/checks.rb +31 -5
  26. data/lib/hecks/projections/site/edge.rb +25 -11
  27. data/lib/hecks/projections/site/routes_ts.rb +50 -27
  28. data/lib/hecks/projections/site/site_cdn.rb +7 -4
  29. data/lib/hecks/projections/site/table/checks.rb +53 -7
  30. data/lib/hecks/projections/site/table/link_builder.rb +64 -0
  31. data/lib/hecks/projections/site/table/row_builder.rb +21 -6
  32. data/lib/hecks/projections/site/table.rb +16 -5
  33. data/lib/hecks/rust_build/kernel_input.rb +18 -6
  34. data/lib/hecks/rust_build/project_rust.rb +6 -132
  35. data/lib/hecks/site/bluebook/site.bluebook +27 -13
  36. data/lib/hecks/storehouse.rb +35 -1
  37. data/lib/hecks/tools/comment_style.rb +4 -2
  38. data/lib/hecks/tools/deploy_recipe.rb +1 -0
  39. data/lib/hecks/tools/rust_comment_style.rb +1 -1
  40. data/lib/hecks/tools/site_routes.rb +69 -28
  41. data/lib/hecks/version.rb +1 -1
  42. data/lib/hecks/vocabulary.rb +1 -1
  43. data/lib/hecks.rb +1 -0
  44. data/rust/build/src/lineage_pass.rs +1 -1
  45. data/rust/build/src/pipeline.rs +1 -1
  46. data/rust/codegen/src/attr.rs +1 -1
  47. data/rust/codegen/src/bridging.rs +1 -1
  48. data/rust/codegen/src/commands.rs +1 -1
  49. data/rust/codegen/src/constraints.rs +1 -1
  50. data/rust/codegen/src/dependency_planning.rs +1 -1
  51. data/rust/codegen/src/domain_generator.rs +2 -2
  52. data/rust/codegen/src/exemplar.rs +1 -1
  53. data/rust/codegen/src/expr_emitter.rs +1 -1
  54. data/rust/codegen/src/fielded.rs +1 -1
  55. data/rust/codegen/src/json_codec.rs +1 -1
  56. data/rust/codegen/src/main.rs +3 -4
  57. data/rust/codegen/src/mutations.rs +1 -1
  58. data/rust/codegen/src/naming.rs +1 -1
  59. data/rust/codegen/src/ports.rs +1 -1
  60. data/rust/codegen/src/queries.rs +3 -3
  61. data/rust/codegen/src/reactions.rs +1 -1
  62. data/rust/codegen/src/read_models.rs +2 -2
  63. data/rust/codegen/src/reference_specs.rs +1 -1
  64. data/rust/codegen/src/registry.rs +4 -4
  65. data/rust/codegen/src/shared.rs +1 -1
  66. data/rust/codegen/src/skip_reason.rs +1 -1
  67. data/rust/codegen/src/types.rs +4 -4
  68. data/rust/host/HECKS_RELEASE +1 -1
  69. data/rust/host/src/auth.rs +25 -1
  70. data/rust/host/src/needs.rs +94 -27
  71. data/rust/host/src/web.rs +9 -5
  72. data/rust/src/exemplar/commands.rs +1 -1
  73. data/rust/src/exemplar/constraints.rs +1 -1
  74. data/rust/src/exemplar/fielded.rs +1 -1
  75. data/rust/src/exemplar/json.rs +1 -1
  76. data/rust/src/exemplar/mod.rs +1 -1
  77. data/rust/src/exemplar/mutations.rs +1 -1
  78. data/rust/src/exemplar/queries.rs +1 -1
  79. data/rust/src/exemplar/reactions.rs +1 -1
  80. data/rust/src/exemplar/read_models.rs +1 -1
  81. data/rust/src/exemplar/registry.rs +1 -1
  82. data/rust/src/exemplar/types.rs +1 -1
  83. data/rust/src/kernel/cli.rs +2 -2
  84. data/rust/src/kernel/read_model.rs +1 -1
  85. data/rust/src/lib.rs +1 -1
  86. metadata +5 -23
  87. data/rust/project/bridging.rb +0 -257
  88. data/rust/project/commands.rb +0 -877
  89. data/rust/project/constraints.rb +0 -86
  90. data/rust/project/dependency_planning.rb +0 -178
  91. data/rust/project/domain_generator.rb +0 -983
  92. data/rust/project/exemplar.rb +0 -148
  93. data/rust/project/expr_emitter.rb +0 -72
  94. data/rust/project/fielded.rb +0 -141
  95. data/rust/project/json_codec.rb +0 -641
  96. data/rust/project/mutations.rb +0 -695
  97. data/rust/project/naming.rb +0 -223
  98. data/rust/project/ports.rb +0 -65
  99. data/rust/project/queries.rb +0 -493
  100. data/rust/project/reactions.rb +0 -317
  101. data/rust/project/read_models.rb +0 -690
  102. data/rust/project/reference_specs.rb +0 -27
  103. data/rust/project/registry.rb +0 -396
  104. data/rust/project/skip_reason.rb +0 -29
  105. data/rust/project/types.rb +0 -225
  106. data/rust/project/write_if_changed.rb +0 -7
  107. data/rust/project.rb +0 -30
  108. 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, an off route that sits in a
9
- # navigation, an indexable route that is not public.
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
- def initialize(rows, problems)
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
- check_unique(@rows.filter_map(&:admin_key), "admin key")
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
- problem(path, "is off and sits in the #{slots.join(' and ')} navigation") if row.off
94
- problem(path, "sits in the navigation but is a #{row.kind}; only a page does") unless row.kind == "page"
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, keyword_init: true)
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 }.freeze
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
- row = Row.new(**fields, verbs: verbs, path: path, source: source,
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.verbs.each do |verb|
127
- next if @vocabulary.fetch(:http_method).include?(verb)
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
- problem(label, "has method #{verb.inspect}; methods are #{@vocabulary.fetch(:http_method).join(', ')}")
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
- Checks.new(@rows, problems).call
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 generated IR.
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
- Array(aggregate["commands"]).each do |command|
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. `HECKS_CODEGEN=ruby` selects the Ruby generator in
21
- # `rust/project` until that is deleted; `HECKS_PARSER=rust` with `HECKS_CODEGEN=rust` runs
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
- if ENV["HECKS_PARSER"] == "rust" && ENV["HECKS_CODEGEN"] == "rust"
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
- ENV["HECKS_CODEGEN"] == "ruby" ? generate_with_ruby : generate_with_codegen
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 `rust/project` hands it to its generators: string-keyed through JSON, with the
109
- # append-optional fields marked, since `ir.json` records them.
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, DomainPath
99
- attribute :run, RunKey
100
- attribute :out, Location, optional: true
101
- attribute :check, Switch, optional: true
102
- attribute :output, Report, optional: true
103
- attribute :refusal, Report, optional: true
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
- # Where routes.ts is written, when not beside the project's bluebook.
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]`. With `check` nothing is written: a
138
- # routes.ts that differs from the table is faulted, naming the file, so a CI job fails on drift.
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, DomainPath
143
- attribute :run, RunKey
144
- attribute :out, Location, optional: true
145
- attribute :check, Switch, optional: true
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"
@@ -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: result.state.nil? ? nil : Doors::JsonDoor.materialize(result.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