hecks 3.2.0 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +3 -3
  3. data/lib/hecks/cli/check_era.rb +3 -39
  4. data/lib/hecks/doors/mcp_door.rb +16 -2
  5. data/lib/hecks/doors/mcp_door_scope.rb +43 -4
  6. data/lib/hecks/doors/mcp_guide_cache.rb +75 -0
  7. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +17 -1
  8. data/lib/hecks/hecks/adapters/codebase/test_runner.rb +10 -2
  9. data/lib/hecks/hecks/adapters/rust_workspace.rb +1 -1
  10. data/lib/hecks/hecks/adapters/site_toolchain.rb +21 -11
  11. data/lib/hecks/language/bluebook/vocabulary.bluebook +7 -7
  12. data/lib/hecks/language/hecksagon/hecksagon.bluebook +1 -1
  13. data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +3 -3
  14. data/lib/hecks/projections/deploy/box/settings.rb +23 -2
  15. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +2 -0
  16. data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +17 -0
  17. data/lib/hecks/projections/deploy/box.rb +13 -2
  18. data/lib/hecks/projections/site/edge/behaviours.rb +5 -3
  19. data/lib/hecks/projections/site/edge/checks.rb +31 -5
  20. data/lib/hecks/projections/site/edge.rb +25 -11
  21. data/lib/hecks/projections/site/routes_ts.rb +50 -27
  22. data/lib/hecks/projections/site/site_cdn.rb +7 -4
  23. data/lib/hecks/projections/site/table/checks.rb +53 -7
  24. data/lib/hecks/projections/site/table/link_builder.rb +64 -0
  25. data/lib/hecks/projections/site/table/row_builder.rb +21 -6
  26. data/lib/hecks/projections/site/table.rb +16 -5
  27. data/lib/hecks/rust_build/kernel_input.rb +18 -6
  28. data/lib/hecks/rust_build/project_rust.rb +6 -132
  29. data/lib/hecks/site/bluebook/site.bluebook +27 -13
  30. data/lib/hecks/storehouse.rb +35 -1
  31. data/lib/hecks/tools/comment_style.rb +4 -2
  32. data/lib/hecks/tools/rust_comment_style.rb +1 -1
  33. data/lib/hecks/tools/site_routes.rb +69 -28
  34. data/lib/hecks/version.rb +1 -1
  35. data/lib/hecks/vocabulary.rb +1 -1
  36. data/lib/hecks.rb +1 -0
  37. data/rust/build/src/lineage_pass.rs +1 -1
  38. data/rust/build/src/pipeline.rs +1 -1
  39. data/rust/codegen/src/attr.rs +1 -1
  40. data/rust/codegen/src/bridging.rs +1 -1
  41. data/rust/codegen/src/commands.rs +1 -1
  42. data/rust/codegen/src/constraints.rs +1 -1
  43. data/rust/codegen/src/dependency_planning.rs +1 -1
  44. data/rust/codegen/src/domain_generator.rs +2 -2
  45. data/rust/codegen/src/exemplar.rs +1 -1
  46. data/rust/codegen/src/expr_emitter.rs +1 -1
  47. data/rust/codegen/src/fielded.rs +1 -1
  48. data/rust/codegen/src/json_codec.rs +1 -1
  49. data/rust/codegen/src/main.rs +3 -4
  50. data/rust/codegen/src/mutations.rs +1 -1
  51. data/rust/codegen/src/naming.rs +1 -1
  52. data/rust/codegen/src/ports.rs +1 -1
  53. data/rust/codegen/src/queries.rs +3 -3
  54. data/rust/codegen/src/reactions.rs +1 -1
  55. data/rust/codegen/src/read_models.rs +2 -2
  56. data/rust/codegen/src/reference_specs.rs +1 -1
  57. data/rust/codegen/src/registry.rs +4 -4
  58. data/rust/codegen/src/shared.rs +1 -1
  59. data/rust/codegen/src/skip_reason.rs +1 -1
  60. data/rust/codegen/src/types.rs +4 -4
  61. data/rust/host/HECKS_RELEASE +1 -1
  62. data/rust/host/src/needs.rs +94 -27
  63. data/rust/src/exemplar/commands.rs +1 -1
  64. data/rust/src/exemplar/constraints.rs +1 -1
  65. data/rust/src/exemplar/fielded.rs +1 -1
  66. data/rust/src/exemplar/json.rs +1 -1
  67. data/rust/src/exemplar/mod.rs +1 -1
  68. data/rust/src/exemplar/mutations.rs +1 -1
  69. data/rust/src/exemplar/queries.rs +1 -1
  70. data/rust/src/exemplar/reactions.rs +1 -1
  71. data/rust/src/exemplar/read_models.rs +1 -1
  72. data/rust/src/exemplar/registry.rs +1 -1
  73. data/rust/src/exemplar/types.rs +1 -1
  74. data/rust/src/kernel/cli.rs +2 -2
  75. data/rust/src/kernel/read_model.rs +1 -1
  76. data/rust/src/lib.rs +1 -1
  77. metadata +3 -23
  78. data/rust/project/bridging.rb +0 -257
  79. data/rust/project/commands.rb +0 -877
  80. data/rust/project/constraints.rb +0 -86
  81. data/rust/project/dependency_planning.rb +0 -178
  82. data/rust/project/domain_generator.rb +0 -983
  83. data/rust/project/exemplar.rb +0 -148
  84. data/rust/project/expr_emitter.rb +0 -72
  85. data/rust/project/fielded.rb +0 -141
  86. data/rust/project/json_codec.rb +0 -641
  87. data/rust/project/mutations.rb +0 -695
  88. data/rust/project/naming.rb +0 -223
  89. data/rust/project/ports.rb +0 -65
  90. data/rust/project/queries.rb +0 -493
  91. data/rust/project/reactions.rb +0 -317
  92. data/rust/project/read_models.rb +0 -690
  93. data/rust/project/reference_specs.rb +0 -27
  94. data/rust/project/registry.rb +0 -396
  95. data/rust/project/skip_reason.rb +0 -29
  96. data/rust/project/types.rb +0 -225
  97. data/rust/project/write_if_changed.rb +0 -7
  98. data/rust/project.rb +0 -30
  99. data/rust/project_rust_pipeline.rb +0 -391
@@ -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
@@ -30,7 +30,7 @@ module Hecks
30
30
  DOC_LINE = %r{\A\s*(?:///(?!/)|//!)}
31
31
 
32
32
  # `// TMPL:name BEGIN`/`END` sentinels are parsed verbatim by
33
- # `rust/project/exemplar.rb`; every prose rule skips them outright.
33
+ # `rust/codegen/src/exemplar.rs`; every prose rule skips them outright.
34
34
  TMPL_MARKER = /\bTMPL:\S+\s+(?:BEGIN|END)\b/i
35
35
  MOD_DOC_LINE = %r{\A\s*//!}
36
36
  ATTR_LINE = /\A\s*#\[/
@@ -14,41 +14,47 @@ module Hecks
14
14
  # it declares an edge, into the behaviours and listener rules of its template
15
15
  # (`Projections::Site::SiteCdn`).
16
16
  #
17
- # hecks site site_projection.project_site [<project>] [--out=<dir>] [--check]
17
+ # hecks site site_projection.project_site [<project>] [--out=<dir>] [--template=<file>]
18
+ # [--extension=ts|mts] [--check]
18
19
  #
19
- # `<project>` is the directory whose `bluebook/` holds the chapter that declares the rows and a
20
- # hecksagon that attaches Site; it defaults to the checkout. `routes.ts` is written to
21
- # `<project>/generated` unless `--out` names another directory. The template is rewritten in
22
- # place between its `BEGIN`/`END GENERATED site_cdn` markers, or under `--out` a copy of it is
23
- # written at the same relative path. With `--check` nothing is written: the tool answers 1 and
24
- # names each file that differs from the table.
20
+ # `<project>` (default: the working directory) holds `bluebook/` and the `vendor/` it attaches
21
+ # from. `routes.ts` goes to `<project>/generated` or to `--out`, anywhere. The template is the
22
+ # `Edge` row's, relative to the project, or `--template`, anywhere; it is rewritten in place
23
+ # between its `BEGIN`/`END GENERATED site_cdn` markers. With `--out` alone, `--out` receives a
24
+ # copy of it instead. `--extension=mts` names the module `routes.mts`; `--check` writes nothing.
25
25
  module SiteRoutes
26
26
  # Where the files go, relative to the project, when `--out` names no directory.
27
27
  DEFAULT_OUT = "generated"
28
28
 
29
- USAGE = "usage: hecks site site_projection.project_site [<project>] [--out=<dir>] [--check]"
29
+ # The extensions the module may take; the first is the default.
30
+ EXTENSIONS = %w[ts mts].freeze
31
+
32
+ USAGE = "usage: hecks site site_projection.project_site [<project>] [--out=<dir>] " \
33
+ "[--template=<file>] [--extension=#{EXTENSIONS.join('|')}] [--check]".freeze
30
34
 
31
35
  module_function
32
36
 
33
37
  # Projects the route table, writes what differs and prints a line for each file written.
34
38
  #
35
39
  # @param argv [Array<String>] the project directory, then the flags
36
- # @param root [String] the checkout, used as the project when `argv` names none
40
+ # @param root [String] the project used when `argv` names none; the working directory
37
41
  # @return [Integer] 0, or 1 when `--check` finds a file out of date
38
- # @raise [SystemExit] with the reason on stderr when the project declares no route table or
39
- # the table is refused
40
- def main(argv, root: Tools::ROOT)
42
+ # @raise [SystemExit] with the reason on stderr when the project declares no route table, the
43
+ # table is refused, or a flag is refused
44
+ def main(argv, root: Dir.pwd)
41
45
  argv = argv.dup
42
- out = nil
46
+ out = template = extension = nil
43
47
  check = false
44
48
  OptionParser.new do |parser|
45
49
  parser.on("--out=DIR") { |value| out = value }
50
+ parser.on("--template=FILE") { |value| template = value }
51
+ parser.on("--extension=EXT") { |value| extension = value }
46
52
  parser.on("--check") { check = true }
47
53
  end.parse!(argv)
48
54
  project = argv.empty? ? root : File.expand_path(argv.shift)
49
55
  abort USAGE unless argv.empty?
50
56
 
51
- files = projection(project, out: out)
57
+ files = projection(project, out: out, template: template, extension: extension)
52
58
  stale = files.reject { |path, text| File.file?(path) && File.read(path) == text }.keys
53
59
  return report(stale, project, check) if check
54
60
 
@@ -63,16 +69,26 @@ module Hecks
63
69
 
64
70
  # @param root [String] the project directory
65
71
  # @param out [String, nil] the directory to write to; `generated/` of the project when nil
72
+ # @param template [String, nil] the template to rewrite in place, wherever it lies; the
73
+ # `Edge` row's template, relative to the project, when nil
74
+ # @param extension [String, nil] `ts` (the default) or `mts`, the module's file extension
66
75
  # @return [Hash{String => String}] each file's absolute path to the text it should hold
67
- # @raise [SystemExit] when the project declares no route table or the table is refused
68
- def projection(root, out: nil)
76
+ # @raise [SystemExit] when the project declares no route table, the table is refused, or a
77
+ # path or the extension is refused
78
+ def projection(root, out: nil, template: nil, extension: nil)
79
+ extension ||= EXTENSIONS.first
80
+ unless EXTENSIONS.include?(extension)
81
+ abort "project_site: --extension is one of #{EXTENSIONS.join(', ')}, not #{extension.inspect}"
82
+ end
83
+ dir = out ? File.expand_path(out) : File.join(root, DEFAULT_OUT)
84
+ abort "project_site: --out #{dir} is a file; it names the directory routes.#{extension} goes in" if File.file?(dir)
85
+
69
86
  registry = registry_for(root)
70
87
  chapter = Projections::Site::Table.chapter(registry)
71
88
  table = Projections::Site::Table.read(chapter, registry: registry)
72
89
  files = Projector.call(:site_routes_ts, bluebook: chapter, options: { table: table })
73
- dir = out ? File.expand_path(out) : File.join(root, DEFAULT_OUT)
74
- written = files.to_h { |name, text| [File.join(dir, name), text] }
75
- written.merge(template_files(root, out, chapter, table, registry))
90
+ written = files.to_h { |name, text| [File.join(dir, name.sub(/\.ts\z/, ".#{extension}")), text] }
91
+ written.merge(template_files(root, out, chapter, table, registry, template: template))
76
92
  rescue Projections::Site::Table::Invalid => e
77
93
  abort "project_site: #{e.message}"
78
94
  end
@@ -84,25 +100,50 @@ module Hecks
84
100
  # @param chapter [Bluebook::Chapter] the chapter that declares the route table
85
101
  # @param table [Projections::Site::Table] the checked table
86
102
  # @param registry [Hecks::Runtime::Registry] the registry the project booted into
103
+ # @param template [String, nil] a template named on the command line, rewritten in place
87
104
  # @return [Hash{String => String}] the template's path to its text; empty with no edge
88
- # @raise [SystemExit] when the template is missing or lacks a region
89
- def template_files(root, out, chapter, table, registry)
90
- edge = Projections::Site::Edge.read(chapter, table: table, vocabulary: Projections::Site::Table.vocabulary(registry))
91
- return {} unless edge
105
+ # @raise [SystemExit] when a template is named and the project declares no edge, the
106
+ # template is missing or lacks a region it needs, or holds a region an edge without a load
107
+ # balancer does not use
108
+ def template_files(root, out, chapter, table, registry, template: nil)
109
+ edge = Projections::Site::Edge.read(chapter, table: table, template: template,
110
+ vocabulary: Projections::Site::Table.vocabulary(registry))
111
+ unless edge
112
+ abort "project_site: --template names #{template}, but the project declares no Edge rows" if template
113
+ return {}
114
+ end
92
115
 
93
116
  regions = Projector.call(:site_cdn, bluebook: chapter, options: { table: table, edge: edge })
94
- relative = edge.setting.template
95
- source = File.join(root, relative)
96
- abort "project_site: the template #{relative} does not exist in #{root}" unless File.file?(source)
117
+ relative = template || edge.setting.template
118
+ source = template ? File.expand_path(template) : File.join(root, relative)
119
+ unless File.file?(source)
120
+ abort "project_site: the template #{relative} does not exist#{" in #{root}" unless template}"
121
+ end
97
122
 
98
- text = regions.reduce(File.read(source)) do |current, (name, block)|
123
+ text = rewritten(File.read(source), regions, relative, edge)
124
+ in_place = template || out.nil?
125
+ { (in_place ? source : File.join(File.expand_path(out), relative)) => text }
126
+ end
127
+
128
+ # @param text [String] the template
129
+ # @param regions [Hash{String => String}] each region's name to the block that goes in it
130
+ # @param relative [String] the template's name, for a message
131
+ # @param edge [Projections::Site::Edge] the checked edge
132
+ # @return [String] the template with each region rewritten
133
+ # @raise [SystemExit] when a region is missing, or a listener_rules region is left behind
134
+ # by an edge with no load balancer
135
+ def rewritten(text, regions, relative, edge)
136
+ if !edge.alb? && Projections::Site::Regions.region?(text, "listener_rules")
137
+ abort "project_site: #{relative} has a BEGIN/END GENERATED site_cdn listener_rules region, " \
138
+ "and the Edge row says alb: false; remove the region"
139
+ end
140
+ regions.reduce(text) do |current, (name, block)|
99
141
  unless Projections::Site::Regions.region?(current, name)
100
142
  abort "project_site: #{relative} has no BEGIN/END GENERATED site_cdn #{name} region"
101
143
  end
102
144
 
103
145
  Projections::Site::Regions.replace(current, name, block)
104
146
  end
105
- { (out ? File.join(File.expand_path(out), relative) : source) => text }
106
147
  end
107
148
 
108
149
  # Loads the project's chapters the way a deploy does: the bluebooks, then the hecksagons that
data/lib/hecks/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  module Hecks
2
2
  # Read directly by the gemspec, so this file must never require the
3
3
  # rest of the gem (its dependencies aren't resolved yet at that point).
4
- VERSION = "3.2.0".freeze
4
+ VERSION = "3.2.1".freeze
5
5
  end
@@ -51,7 +51,7 @@ module Hecks
51
51
  ].freeze,
52
52
  "CiGate" => [
53
53
  {"name"=>"runtime_changed", "workflow"=>"ci.yml", "mode"=>"touches", "pattern"=>"^lib/hecks/runtime/", "push"=>"skip", "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|project)/|rust/project\\.rb$|rust/project_rust_pipeline\\.rb$|\\.rubocop\\.yml$|\\.rubocop_todo\\.yml$|\\.mcp\\.json$|\\.rspec-local\\.example$|README\\.md$|CHANGELOG\\.md$|CONTRIBUTING\\.md$|SECURITY\\.md$|LICENSE$|\\.gitignore$)", "push"=>"before_sha", "label"=>"does this change touch anything rspec_postgres_io_parallel covers?"}.freeze
54
+ {"name"=>"postgres_io_relevant_changed", "workflow"=>"ci-postgres-io-parallel.yml", "mode"=>"skips_unless", "pattern"=>"^(docs/|editors/|release/|deploy/|\\.claude/|\\.githooks/|rust/(parser|codegen|host|build|lsp|web|tests)/|\\.rubocop\\.yml$|\\.rubocop_todo\\.yml$|\\.mcp\\.json$|\\.rspec-local\\.example$|README\\.md$|CHANGELOG\\.md$|CONTRIBUTING\\.md$|SECURITY\\.md$|LICENSE$|\\.gitignore$)", "push"=>"before_sha", "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,
data/lib/hecks.rb CHANGED
@@ -34,6 +34,7 @@ require_relative "hecks/doors/cli_runner"
34
34
  require_relative "hecks/storehouse"
35
35
  require_relative "hecks/mcp_stdio_guard"
36
36
  require_relative "hecks/doors/mcp_door_scope"
37
+ require_relative "hecks/doors/mcp_guide_cache"
37
38
  require_relative "hecks/framework"
38
39
  require_relative "hecks/vendoring"
39
40
  require_relative "hecks/embryonaut_bluebook"
@@ -1,5 +1,5 @@
1
1
  //! Rust port of `Exporter.lineage` (`lib/hecks/projector/exporter.rb`), by way
2
- //! of `rust/project_rust_pipeline.rb::derive_lineage`.
2
+ //! of the retired Ruby pipeline's `derive_lineage`.
3
3
 
4
4
  use std::collections::HashMap;
5
5
  use std::path::Path;
@@ -1,4 +1,4 @@
1
- //! Rust port of `RustProjectPipeline.call` (`rust/project_rust_pipeline.rb`) —
1
+ //! Rust port of the retired Ruby pipeline's `RustProjectPipeline.call` —
2
2
  //! this crate's own orchestration core.
3
3
 
4
4
  use std::path::{Path, PathBuf};
@@ -1,5 +1,5 @@
1
1
  //! Free-function accessors over an `IR::Attribute#to_h`-shaped `Json::Object`, mirroring the
2
- //! bare Hash lookups in rust/project/*.rb.
2
+ //! bare Hash lookups the retired Ruby generator used.
3
3
 
4
4
  use crate::json::Json;
5
5
 
@@ -1,4 +1,4 @@
1
- //! Port of `rust/project/bridging.rb`: cross-type coercion checks for `:set` and `:append` RHS.
1
+ //! Port of the retired Ruby generator's `bridging.rb`: cross-type coercion checks for `:set` and `:append` RHS.
2
2
 
3
3
  use crate::json::Json;
4
4
  use crate::literal::Literal;
@@ -1,4 +1,4 @@
1
- //! Port of `rust/project/commands.rb`: emits the per-command dispatch functions.
1
+ //! Port of the retired Ruby generator's `commands.rb`: emits the per-command dispatch functions.
2
2
 
3
3
  use crate::exemplar::Exemplar;
4
4
  use crate::json::Json;
@@ -1,4 +1,4 @@
1
- //! Port of `rust/project/constraints.rb`: `admits:` and `pattern:` check emission.
1
+ //! Port of the retired Ruby generator's `constraints.rb`: `admits:` and `pattern:` check emission.
2
2
 
3
3
  use crate::exemplar::Exemplar;
4
4
  use crate::json::Json;
@@ -1,4 +1,4 @@
1
- //! Port of `rust/project/dependency_planning.rb`, an independent re-derivation of the
1
+ //! Port of the retired Ruby generator's `dependency_planning.rb`, an independent re-derivation of the
2
2
  //! Analyzer's `complete_state? && state_independent?` predicate. Mirrors the Ruby file.
3
3
 
4
4
  use crate::json::Json;
@@ -1,4 +1,4 @@
1
- //! Port of `rust/project/domain_generator.rb`'s `DomainGenerator.call`: the per-aggregate
1
+ //! Port of the retired Ruby generator's `domain_generator.rb`'s `DomainGenerator.call`: the per-aggregate
2
2
  //! `.rs` files plus `registry.rs`. `metadata.rs` and `ir.json` are not emitted here.
3
3
 
4
4
  use crate::exemplar::Exemplar;
@@ -1181,7 +1181,7 @@ pub fn generate(
1181
1181
  None,
1182
1182
  None,
1183
1183
  Some(format!(
1184
- "read via rust/host's journal::read_lineage_head_all/_by_id, written via journal::append_lineage_mutation — both generic over storage_name (\"{}\"), dispatched OUTSIDE the WASM kernel/InMemoryRepository path entirely, matching Ruby's own CommandInterpreter routing for a Postgres-bound aggregate (rust/project.rb's own header)",
1184
+ "read via rust/host's journal::read_lineage_head_all/_by_id, written via journal::append_lineage_mutation — both generic over storage_name (\"{}\"), dispatched OUTSIDE the WASM kernel/InMemoryRepository path entirely, matching Ruby's own CommandInterpreter routing for a Postgres-bound aggregate (rust/codegen/src/domain_generator.rs)",
1185
1185
  lineage_aggregate.get("storage_name").map(Json::to_s).unwrap_or_default()
1186
1186
  )),
1187
1187
  );
@@ -1,4 +1,4 @@
1
- //! Port of `rust/project/exemplar.rb`: loads the template shapes in `rust/src/exemplar/*.rs`.
1
+ //! Port of the retired Ruby generator's `exemplar.rb`: loads the template shapes in `rust/src/exemplar/*.rs`.
2
2
  //! Both codegens read those files at runtime, so the shapes cannot drift.
3
3
  use std::collections::HashMap;
4
4
  use std::path::PathBuf;
@@ -1,4 +1,4 @@
1
- //! Port of `rust/project/expr_emitter.rb`: turns each IR rule's `ast` tree into `Expr` source.
1
+ //! Port of the retired Ruby generator's `expr_emitter.rb`: turns each IR rule's `ast` tree into `Expr` source.
2
2
  //! A literal-array `include?` never reaches here; `AstJson` rewrites it to an or of equalities.
3
3
 
4
4
  use crate::json::Json;