hecks 3.0.4 → 3.1.0

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 (159) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +25 -7
  3. data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
  4. data/lib/hecks/adapters/driven/lambda.rb +1 -1
  5. data/lib/hecks/adapters/driven/tenant_provisioner.rb +2 -2
  6. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +59 -32
  7. data/lib/hecks/bluebook/hexagon.rb +45 -25
  8. data/lib/hecks/bluebook/model_check.rb +7 -7
  9. data/lib/hecks/chapters.rb +31 -3
  10. data/lib/hecks/cli/check_era.rb +1 -1
  11. data/lib/hecks/cli/console.rb +24 -8
  12. data/lib/hecks/cli/deploy_template_diff.rb +1 -1
  13. data/lib/hecks/cli/doc_coverage.rb +1 -1
  14. data/lib/hecks/cli/domain_stub.rb +128 -0
  15. data/lib/hecks/cli/interview_draft.rb +246 -0
  16. data/lib/hecks/cli/model_check.rb +1 -1
  17. data/lib/hecks/cli/project_cli.rb +69 -18
  18. data/lib/hecks/cli/reference.rb +2 -1
  19. data/lib/hecks/cli/refresh_rspec_runtime_baseline.rb +1 -1
  20. data/lib/hecks/cli/seed_semantics_corpus.rb +38 -9
  21. data/lib/hecks/cli/spec_example.rb +1 -1
  22. data/lib/hecks/cli/stress_concurrency_specs.rb +14 -5
  23. data/lib/hecks/corpus.rb +11 -8
  24. data/lib/hecks/deploy/bluebook/deploy.bluebook +85 -12
  25. data/lib/hecks/deploy/oidc.json +5 -0
  26. data/lib/hecks/doc/reference.rb +4 -3
  27. data/lib/hecks/doors/cli_door.rb +13 -13
  28. data/lib/hecks/doors/cli_runner.rb +52 -32
  29. data/lib/hecks/doors/launcher_options.rb +23 -5
  30. data/lib/hecks/doors/mcp_door.rb +40 -18
  31. data/lib/hecks/doors/mcp_door_scope.rb +244 -24
  32. data/lib/hecks/doors.rb +1 -1
  33. data/lib/hecks/embryonaut_bluebook.rb +2 -2
  34. data/lib/hecks/forms/banking_presentation.rb +1 -1
  35. data/lib/hecks/framework/bluebook/compliance.bluebook +1 -1
  36. data/lib/hecks/framework/bluebook/console_settings.bluebook +1 -1
  37. data/lib/hecks/framework/bluebook/framework.hecksagon +4 -4
  38. data/lib/hecks/framework.rb +1 -1
  39. data/lib/hecks/fuzzing/combination_miner.rb +1 -1
  40. data/lib/hecks/fuzzing/concurrent_dispatch.rb +1 -1
  41. data/lib/hecks/fuzzing/differential.rb +4 -2
  42. data/lib/hecks/fuzzing/era_boundary.rb +1 -1
  43. data/lib/hecks/fuzzing/form_census.rb +2 -2
  44. data/lib/hecks/fuzzing/generated_domain_check.rb +1 -1
  45. data/lib/hecks/fuzzing/isolated_boot.rb +10 -3
  46. data/lib/hecks/fuzzing/persistence_parity.rb +2 -2
  47. data/lib/hecks/fuzzing/target_capabilities.rb +3 -3
  48. data/lib/hecks/gate/stages.yml +8 -4
  49. data/lib/hecks/grammar/grammar.hecksagon +2 -2
  50. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +4 -4
  51. data/lib/hecks/hecks/adapters/host_http.rb +4 -3
  52. data/lib/hecks/hecks/adapters/local_files.rb +59 -0
  53. data/lib/hecks/hecks/adapters/site_toolchain.adapter +7 -0
  54. data/lib/hecks/hecks/adapters/site_toolchain.rb +54 -0
  55. data/lib/hecks/hecks/codebase.bluebook +4 -3
  56. data/lib/hecks/hecks/context_map.hecksagon +2 -2
  57. data/lib/hecks/hecks/custodian.bluebook +45 -3
  58. data/lib/hecks/hecks/hecks.hecksagon +10 -4
  59. data/lib/hecks/hecks/hecks.world +7 -4
  60. data/lib/hecks/hecks/site.behaviors +17 -0
  61. data/lib/hecks/hecks/site.world +10 -0
  62. data/lib/hecks/language/bluebook/bluebook.hecksagon +1 -1
  63. data/lib/hecks/language/hecksagon/hecksagon.bluebook +24 -47
  64. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +4 -4
  65. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +2 -1
  66. data/lib/hecks/projections/deploy/box/settings.rb +248 -0
  67. data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +267 -0
  68. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +70 -0
  69. data/lib/hecks/projections/deploy/box/templates/fetch-secrets.sh +21 -0
  70. data/lib/hecks/projections/deploy/box/templates/rds.yaml.tmpl +165 -0
  71. data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +43 -0
  72. data/lib/hecks/projections/deploy/box.rb +387 -0
  73. data/lib/hecks/projections/deploy/fargate/cdn.rb +13 -1
  74. data/lib/hecks/projections/deploy/fargate.rb +1 -1
  75. data/lib/hecks/projections/deploy/lambda.rb +11 -10
  76. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +1 -1
  77. data/lib/hecks/projections/diagrams.rb +2 -2
  78. data/lib/hecks/projections/site/edge/behaviours.rb +107 -0
  79. data/lib/hecks/projections/site/edge/checks.rb +137 -0
  80. data/lib/hecks/projections/site/edge/pattern.rb +50 -0
  81. data/lib/hecks/projections/site/edge/rules.rb +128 -0
  82. data/lib/hecks/projections/site/edge.rb +136 -0
  83. data/lib/hecks/projections/site/regions.rb +44 -0
  84. data/lib/hecks/projections/site/routes_ts.rb +284 -0
  85. data/lib/hecks/projections/site/site_cdn.rb +78 -0
  86. data/lib/hecks/projections/site/table/checks.rb +124 -0
  87. data/lib/hecks/projections/site/table/row_builder.rb +155 -0
  88. data/lib/hecks/projections/site/table.rb +98 -0
  89. data/lib/hecks/projections.rb +2 -0
  90. data/lib/hecks/projector/cli_projector.rb +172 -95
  91. data/lib/hecks/quality_control/adapters/agent.adapter +1 -1
  92. data/lib/hecks/quality_control/adapters/agent.rb +2 -2
  93. data/lib/hecks/quality_control/cli/qa_concurrency_racer.rb +3 -3
  94. data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +8 -8
  95. data/lib/hecks/quality_control/cli/qa_domain_novelty.rb +3 -3
  96. data/lib/hecks/quality_control/cli/qa_generated_domains.rb +4 -4
  97. data/lib/hecks/quality_control/cli/qa_log_bug.rb +2 -2
  98. data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +6 -5
  99. data/lib/hecks/quality_control/cli/qa_postgres_migrate.rb +2 -2
  100. data/lib/hecks/quality_control/cli/qa_postgres_role.rb +5 -5
  101. data/lib/hecks/quality_control/cli/qa_pr_check.rb +3 -3
  102. data/lib/hecks/quality_control/cli/qa_sweep/all_mode.rb +3 -3
  103. data/lib/hecks/quality_control/cli/qa_sweep/checks.rb +2 -2
  104. data/lib/hecks/quality_control/cli/qa_sweep.rb +10 -9
  105. data/lib/hecks/quality_control/cli/qa_tick.rb +3 -3
  106. data/lib/hecks/quality_control/quality_control.bluebook +18 -18
  107. data/lib/hecks/quality_control/quality_control.hecksagon +1 -1
  108. data/lib/hecks/runtime/command_interpreter.rb +2 -0
  109. data/lib/hecks/runtime/interpreting.rb +21 -0
  110. data/lib/hecks/runtime/invocation.rb +5 -3
  111. data/lib/hecks/runtime/registry/verification.rb +15 -29
  112. data/lib/hecks/runtime/registry.rb +14 -20
  113. data/lib/hecks/rust_build/append_optionals.rb +79 -0
  114. data/lib/hecks/rust_build/conformance_fuzz.rb +2 -1
  115. data/lib/hecks/rust_build/domain_name.rb +26 -0
  116. data/lib/hecks/rust_build/kernel_input.rb +58 -0
  117. data/lib/hecks/rust_build/project_rust/codegen_run.rb +143 -0
  118. data/lib/hecks/rust_build/project_rust/root_mod.rb +1 -1
  119. data/lib/hecks/rust_build/project_rust.rb +57 -24
  120. data/lib/hecks/rust_build/rust_literal.rb +33 -0
  121. data/lib/hecks/rust_build/write_if_changed.rb +86 -0
  122. data/lib/hecks/site/bluebook/site.bluebook +205 -0
  123. data/lib/hecks/site/bluebook/site.hecksagon +5 -0
  124. data/lib/hecks/site/bluebook/site.ports.hecksagon +15 -0
  125. data/lib/hecks/sme/bluebook/sme.bluebook +336 -0
  126. data/lib/hecks/sme/bluebook/sme.hecksagon +5 -0
  127. data/lib/hecks/storehouse.rb +37 -7
  128. data/lib/hecks/tenancy/bluebook/tenancy.bluebook +1 -1
  129. data/lib/hecks/three_zero/forms.yml +88 -88
  130. data/lib/hecks/tools/ci_gates.rb +1 -1
  131. data/lib/hecks/tools/deploy_recipe.rb +14 -6
  132. data/lib/hecks/tools/oidc_manifests.rb +2 -1
  133. data/lib/hecks/tools/sequence_script.rb +2 -2
  134. data/lib/hecks/tools/site_routes.rb +152 -0
  135. data/lib/hecks/tools/tenant_provisioning.rb +1 -1
  136. data/lib/hecks/tools.rb +1 -0
  137. data/lib/hecks/version.rb +1 -1
  138. data/rust/build/src/cargo_sync.rs +1 -1
  139. data/rust/build/src/pipeline.rs +23 -16
  140. data/rust/build/src/resolve.rs +39 -6
  141. data/rust/host/HECKS_RELEASE +1 -1
  142. data/rust/host/src/api.rs +1 -1
  143. data/rust/host/src/dispatch.rs +87 -7
  144. data/rust/host/src/needs.rs +111 -0
  145. data/rust/host/src/web/newsletter/template.rs +69 -0
  146. data/rust/host/src/web/newsletter/tests.rs +43 -0
  147. data/rust/host/src/web/newsletter.rs +6 -1
  148. data/rust/host/src/web/newsletter_send.rs +1 -1
  149. data/rust/parser/src/keywords.rs +7 -6
  150. data/rust/parser/src/main.rs +4 -3
  151. data/rust/parser/src/parse/chapter.rs +1 -1
  152. data/rust/parser/src/parse/hecksagon.rs +25 -10
  153. data/rust/project/mutations.rb +4 -30
  154. data/rust/project/naming.rb +8 -28
  155. data/rust/project/write_if_changed.rb +4 -80
  156. data/rust/project_rust_pipeline.rb +16 -12
  157. data/rust/src/kernel/cli.rs +2 -0
  158. data/rust/src/kernel/needs.rs +74 -1
  159. metadata +39 -2
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hecks/vocabulary"
4
+ require "hecks/bluebook/model_check"
5
+
6
+ module Hecks
7
+ module RustBuild
8
+ # Whether a domain's directory name can double as a Rust module identifier and a Cargo
9
+ # feature name. The reserved-word half is `ModelCheck.rust_reserved_name_findings`, the one
10
+ # shared check.
11
+ module DomainName
12
+ # Every key `rust/Cargo.toml` uses outside `[features]`, plus `default`, which Cargo
13
+ # reserves for the auto-enabled feature set. Declared once as the `CargoReservedName`
14
+ # vocabulary.
15
+ CARGO_RESERVED = Hecks::Vocabulary.fetch("CargoReservedName")
16
+
17
+ # @param name [String] the domain's directory name
18
+ # @return [Boolean] whether it is a plain lowercase identifier and no reserved word
19
+ def self.valid?(name)
20
+ text = name.to_s
21
+ text.match?(/\A[a-z_][a-z0-9_]*\z/) &&
22
+ Hecks::Bluebook::ModelCheck.rust_reserved_name_findings(domain_name: text).empty?
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../rust_build"
5
+
6
+ module Hecks
7
+ module RustBuild
8
+ # The stdin a compiled domain binary reads: the steps to run, plus the declared argument
9
+ # defaults the host would hand its kernel.
10
+ #
11
+ # The kernel has no IR of its own, so a command's declared defaults reach it as a top-level
12
+ # `"defaults"` table, `{ "Domain::Aggregate.Command" => { "attribute" => value } }`, the same
13
+ # one the Rust host builds from the domain's IR. A harness that sends only `"steps"` would
14
+ # leave the kernel refusing an argument Ruby fills, so every conformance caller builds its
15
+ # input here.
16
+ module KernelInput
17
+ module_function
18
+
19
+ # @param domain_path [String] the domain's directory; its basename names the generated module
20
+ # @param steps [Array<Hash>] the steps to run
21
+ # @return [Hash{String => Object}] the input; `"defaults"` is there only when one is declared
22
+ def build(domain_path, steps)
23
+ input = { "steps" => steps }
24
+ defaults = defaults_for(domain_path)
25
+ input["defaults"] = defaults unless defaults.empty?
26
+ input
27
+ end
28
+
29
+ # @param domain_path [String] the domain's directory
30
+ # @return [String] the input as JSON, ready for the binary's stdin
31
+ def json(domain_path, steps) = JSON.generate(build(domain_path, steps))
32
+
33
+ # Each command's declared defaults, read from the domain's generated IR.
34
+ #
35
+ # @param domain_path [String] the domain's directory
36
+ # @return [Hash{String => Hash{String => Object}}] empty when the IR is absent or has none
37
+ def defaults_for(domain_path)
38
+ ir = generated_ir(domain_path) or return {}
39
+ domain = ir["name"].to_s
40
+ 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
46
+ end
47
+ end
48
+
49
+ # @param domain_path [String] the domain's directory
50
+ # @return [Hash, nil] the parsed generated IR, or nil when the domain has none
51
+ def generated_ir(domain_path)
52
+ feature = File.basename(domain_path.to_s.chomp("/")).downcase
53
+ path = File.join(RustBuild.rust_dir, "src", "generated", feature, "ir.json")
54
+ File.file?(path) ? JSON.parse(File.read(path)) : nil
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "tmpdir"
6
+ require_relative "../rust_literal"
7
+ require_relative "../write_if_changed"
8
+
9
+ module Hecks
10
+ module RustBuild
11
+ class ProjectRust
12
+ # Generates a domain's Rust by running `hecks-codegen full` on IR the caller built from the
13
+ # live registry, into a scratch directory, then copies only the files whose bytes changed
14
+ # into `src/generated/`. An unchanged file keeps its mtime, so Cargo does not rebuild.
15
+ class CodegenRun
16
+ META_LABEL = "the self-hosted language (lib/hecks/language/bluebook)"
17
+
18
+ # One chapter's IR, generated into its own module.
19
+ Chapter = Struct.new(:mod_name, :label, :ir)
20
+
21
+ # The `hecks-codegen` crate: the workspace's own copy when it carries one (a packaged
22
+ # workspace does), otherwise the checkout's, so a scratch crate holding only the generated
23
+ # tree still finds it.
24
+ #
25
+ # @param rust_dir [String] the workspace being generated into
26
+ # @return [String] the crate's directory
27
+ def self.crate_dir(rust_dir)
28
+ own = File.join(rust_dir, "codegen")
29
+ File.directory?(own) ? own : File.join(RustBuild::ROOT, "rust/codegen")
30
+ end
31
+
32
+ # Where Cargo puts the binary: under `CARGO_TARGET_DIR` when a build sets it (a copy of the
33
+ # packaged workspace does, so nothing is written into the installed gem), else in the crate.
34
+ #
35
+ # @param rust_dir [String] the workspace being generated into
36
+ # @param env [#fetch] the environment Cargo will run with
37
+ # @return [String] the binary's path
38
+ def self.binary(rust_dir, env: ENV)
39
+ target = env.fetch("CARGO_TARGET_DIR") { File.join(crate_dir(rust_dir), "target") }
40
+ File.join(target, "debug/hecks-codegen")
41
+ end
42
+
43
+ # @param rust_dir [String] the workspace being generated into
44
+ # @param out_root [String] the crate's `src/generated`
45
+ # @param target [Chapter] the domain being projected
46
+ # @param chapters [Array<Chapter>] every other bluebook the target attached
47
+ # @param meta [Hash] the self-hosted language's IR
48
+ def initialize(rust_dir:, out_root:, target:, chapters:, meta:)
49
+ @rust_dir = rust_dir
50
+ @out_root = out_root
51
+ @target = target
52
+ @chapters = chapters
53
+ @meta = meta
54
+ end
55
+
56
+ # @return [void]
57
+ # @raise [Failure] when `hecks-codegen` cannot be built or exits non-zero
58
+ def call
59
+ build_binary
60
+ Dir.mktmpdir("hecks-codegen-full") do |tmp|
61
+ stage = File.join(tmp, "generated")
62
+ FileUtils.mkdir_p(stage)
63
+ generate_meta(stage, tmp)
64
+ generate_target(stage, tmp)
65
+ sync(stage)
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ def build_binary
72
+ dir = self.class.crate_dir(@rust_dir)
73
+ return if system("cargo", "build", "--quiet", chdir: dir, out: $stdout, err: $stderr)
74
+
75
+ raise Failure, "hecks project_rust: cargo build failed in #{dir} — run it there directly to see why"
76
+ end
77
+
78
+ def generate_meta(stage, tmp)
79
+ ir_path = write_ir(tmp, "meta", @meta)
80
+ run("full", stage, "meta", META_LABEL, ir_path)
81
+ write_sidecars(File.join(stage, "meta"), @meta, META_LABEL)
82
+ end
83
+
84
+ def generate_target(stage, tmp)
85
+ args = [stage, @target.mod_name, @target.label, write_ir(tmp, @target.mod_name, @target.ir)]
86
+ @chapters.each do |chapter|
87
+ args.push(chapter.mod_name, chapter.label, write_ir(tmp, chapter.mod_name, chapter.ir))
88
+ end
89
+ run("full", *args)
90
+ [@target, *@chapters].each do |chapter|
91
+ write_sidecars(File.join(stage, chapter.mod_name), chapter.ir, chapter.label)
92
+ end
93
+ end
94
+
95
+ def write_ir(tmp, name, tree)
96
+ path = File.join(tmp, "#{name}_ir.json")
97
+ File.write(path, JSON.pretty_generate(tree))
98
+ path
99
+ end
100
+
101
+ # `ir.json` is what `rust/host` reads at runtime, and `metadata.rs` embeds the same text;
102
+ # the codegen binary writes neither.
103
+ def write_sidecars(mod_dir, tree, label)
104
+ text = JSON.pretty_generate(tree)
105
+ File.write(File.join(mod_dir, "ir.json"), text)
106
+ File.write(File.join(mod_dir, "metadata.rs"), <<~RUST)
107
+ // GENERATED by hecks project_rust — #{label}'s own canonical IR,
108
+ // embedded for runtime self-description. Not read by any dispatch
109
+ // function in this module — introspection only.
110
+ pub const IR_JSON: &str = #{RustLiteral.string(text)};
111
+ RUST
112
+ end
113
+
114
+ def run(*args)
115
+ return if system(self.class.binary(@rust_dir), *args, out: $stdout, err: $stderr)
116
+
117
+ raise Failure, "hecks project_rust: hecks-codegen #{args.first} failed"
118
+ end
119
+
120
+ # Each module directory is tracked, so a file an earlier run wrote and this one did not
121
+ # (a removed aggregate) is pruned. Pruning waits for the whole copy to succeed: after a
122
+ # failure, the files not yet reached would look like orphans and be deleted.
123
+ def sync(stage)
124
+ Dir.children(stage).sort.each do |name|
125
+ source = File.join(stage, name)
126
+ dest = File.join(@out_root, name)
127
+ WriteIfChanged.push_directory(dest)
128
+ copy_changed(source, dest)
129
+ WriteIfChanged.pop_and_prune(dest)
130
+ end
131
+ end
132
+
133
+ def copy_changed(source, dest)
134
+ Dir.children(source).sort.each do |file|
135
+ path = File.join(dest, file)
136
+ wrote = WriteIfChanged.call(path, File.read(File.join(source, file), encoding: "UTF-8"))
137
+ puts(wrote ? "wrote #{path}" : "#{path} unchanged")
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -43,7 +43,7 @@ module Hecks
43
43
  @file.puts "// chapter (governance/identity — no merged.rs of its own, so absent"
44
44
  @file.puts "// from `domains`) stays unconditional: it has no single feature of its"
45
45
  @file.puts "// own to gate behind, and is compiled in by whichever domain(s) attach"
46
- @file.puts "// it via `uses_framework`."
46
+ @file.puts "// it via `attaches`."
47
47
  end
48
48
 
49
49
  def modules
@@ -5,20 +5,24 @@ require "json"
5
5
  require_relative "../../hecks"
6
6
  require_relative "../bluebook/meta_validator"
7
7
  require_relative "../ports/persistence/plugins/era"
8
- require_relative "../../../rust/project"
8
+ require_relative "append_optionals"
9
+ require_relative "domain_name"
10
+ require_relative "write_if_changed"
9
11
 
10
12
  module Hecks
11
13
  module RustBuild
12
14
  # Generates Rust source for one domain into the workspace's `src/generated/`, and keeps the
13
- # workspace's `Cargo.toml` features in step; `rust/project.rb` (`RustProjection`) holds the
14
- # architecture of the generator itself.
15
+ # workspace's `Cargo.toml` features in step.
15
16
  #
16
17
  # ProjectRust.call(["path/to/domain"])
17
18
  #
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.
23
+ #
18
24
  # The era plugin is required unconditionally so the generator works for any domain, lineage
19
25
  # capable or not; `pg` stays lazy inside it, so nothing here opens a database connection.
20
- # With `HECKS_PARSER=rust` and `HECKS_CODEGEN=rust` together, the whole pipeline runs through
21
- # `rust/project_rust_pipeline.rb` instead, with no Ruby load of the domain.
22
26
  class ProjectRust
23
27
  # The optional attachments `rust/host` reads from `ir.json`, each omitted when nothing
24
28
  # attached provides it: exporter method, then the key it is written under.
@@ -43,6 +47,7 @@ module Hecks
43
47
  def call
44
48
  validate_name!
45
49
  if ENV["HECKS_PARSER"] == "rust" && ENV["HECKS_CODEGEN"] == "rust"
50
+ require_relative "../../../rust/project"
46
51
  require_relative "../../../rust/project_rust_pipeline"
47
52
  RustProjectPipeline.call(@domain)
48
53
  else
@@ -56,13 +61,13 @@ module Hecks
56
61
  # The name becomes a directory, a `pub mod` identifier and a Cargo feature, so it is checked
57
62
  # before any side effect (the `meta/` directory is rewritten below).
58
63
  def validate_name!
59
- return if RustProjection::Projector.valid_domain_mod_name?(@mod_name)
64
+ return if DomainName.valid?(@mod_name)
60
65
 
61
66
  raise Failure, "hecks project_rust: domain name #{@mod_name.inspect} (from #{@domain.inspect}) can't " \
62
67
  "be used as-is — it has to double as a Rust module identifier and a Cargo feature " \
63
68
  "name, and this one is either not a plain lowercase identifier, is a Rust keyword, " \
64
69
  "or collides with a reserved Cargo.toml key " \
65
- "(#{RustProjection::Projector::CARGO_RESERVED_DOMAIN_NAMES.join(', ')}). " \
70
+ "(#{DomainName::CARGO_RESERVED.join(', ')}). " \
66
71
  "Rename the domain directory."
67
72
  end
68
73
 
@@ -72,17 +77,45 @@ module Hecks
72
77
  @out_root = File.join(@rust_dir, "src/generated")
73
78
  FileUtils.mkdir_p(@out_root)
74
79
  build_target_ir
75
- write_meta
76
- write_target
80
+ FileUtils.rm_rf(File.join(@out_root, "active"))
81
+ ENV["HECKS_CODEGEN"] == "ruby" ? generate_with_ruby : generate_with_codegen
77
82
  write_root_mod
78
83
  sync_cargo_features
79
84
  end
80
85
 
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
+ # `hecks-codegen` is the generator; this process only builds the IR it reads from the live
94
+ # registry, so a domain's persistence, seams, translations and source text reach `ir.json`.
95
+ def generate_with_codegen
96
+ meta = Hecks::Projector::Exporter.call(Hecks::Bluebook::MetaValidator.grammar_registry).fetch("Bluebook")
97
+ vendored = @registry.hecksagons.values.flat_map(&:vendored_packages)
98
+ chapters = (@registry.bluebooks.keys - [@domain_name]).map do |name|
99
+ ir = Hecks::Projector::Exporter.call(@registry).fetch(name)
100
+ CodegenRun::Chapter.new(name.downcase, "#{@domain} (#{attachment(name, vendored)})", prepared_ir(ir))
101
+ end
102
+ CodegenRun.new(
103
+ rust_dir: @rust_dir, out_root: @out_root, meta: prepared_ir(meta), chapters: chapters,
104
+ target: CodegenRun::Chapter.new(@mod_name, @domain, prepared_ir(@ir, shaped: false))
105
+ ).call
106
+ end
107
+
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.
110
+ def prepared_ir(tree, shaped: true)
111
+ AppendOptionals.mark(shaped ? json_shaped(tree) : tree)
112
+ end
113
+
81
114
  # Round-trips a value through JSON so generators see the string-keyed shape a real `ir.json`
82
115
  # carries, never live Ruby symbols.
83
116
  def json_shaped(payload) = JSON.parse(JSON.generate(payload), symbolize_names: true)
84
117
 
85
- # `root:` is the domain's own directory: a domain that declares `uses_embryonaut_bluebook`
118
+ # `root:` is the domain's own directory: a domain that declares `attaches ... from: :vendor`
86
119
  # resolves its vendored chapters beneath it.
87
120
  def load_registry
88
121
  @registry = Hecks::Runtime::Registry.new(root: File.expand_path(@domain))
@@ -100,7 +133,7 @@ module Hecks
100
133
  @domain_name = @registry.bluebooks.keys.first
101
134
  end
102
135
 
103
- # Translation edges, hecksagons (whose `uses_framework` loads a framework chapter into the
136
+ # Translation edges, hecksagons (whose `attaches` loads a framework chapter into the
104
137
  # same registry) and worlds (whose `default_adapter` binds what a hecksagon leaves out), in
105
138
  # the order the language loads them.
106
139
  def load_siblings
@@ -134,7 +167,7 @@ module Hecks
134
167
  if !seams[:membership].empty? && seams[:identity].empty?
135
168
  raise Failure, "#{@domain_name} provides membership but not identity — rust/host Google sign-in " \
136
169
  "cannot register or link an identity from ir.json. Attach Identity " \
137
- "(`uses_framework \"Identity\"` plus a sibling Hecks.hecksagon \"Identity\") so the " \
170
+ "(`attaches \"Identity\"` plus a sibling Hecks.hecksagon \"Identity\") so the " \
138
171
  "hecksagon, not a deploy-time guess, names the identity verbs."
139
172
  end
140
173
  add_edges_and_source
@@ -163,8 +196,7 @@ module Hecks
163
196
  # Tracked per directory: several domains coexist on disk, and `pop_and_prune` deletes only
164
197
  # the files this run never touched. `active` is a set of feature re-exports in the root
165
198
  # `mod.rs`, never a directory.
166
- RustProjection::WriteIfChanged.push_directory(dir)
167
- FileUtils.rm_rf(File.join(@out_root, "active"))
199
+ WriteIfChanged.push_directory(dir)
168
200
  result = RustProjection::DomainGenerator.call(
169
201
  meta_ir, "the self-hosted language (lib/hecks/language/bluebook)", dir, "meta"
170
202
  )
@@ -176,12 +208,12 @@ module Hecks
176
208
  managers: meta_ir[:process_managers],
177
209
  keys: [[meta_ir[:name], result[:aggregates].map { |a| a[:name] }]])
178
210
  append_merged_mod(File.join(dir, "mod.rs"))
179
- RustProjection::WriteIfChanged.pop_and_prune(dir)
211
+ WriteIfChanged.pop_and_prune(dir)
180
212
  end
181
213
 
182
214
  def write_target
183
215
  dir = File.join(@out_root, @mod_name)
184
- RustProjection::WriteIfChanged.push_directory(dir)
216
+ WriteIfChanged.push_directory(dir)
185
217
  result = RustProjection::DomainGenerator.call(@ir, @domain, dir, @mod_name)
186
218
  chapters = write_chapters
187
219
  aggregates = result[:aggregates] + chapters[:aggregates]
@@ -192,10 +224,10 @@ module Hecks
192
224
  managers: @ir[:process_managers] + chapters[:process_managers],
193
225
  keys: reference_keys(result[:aggregates], chapters))
194
226
  append_merged_mod(File.join(dir, "mod.rs"))
195
- RustProjection::WriteIfChanged.pop_and_prune(dir)
227
+ WriteIfChanged.pop_and_prune(dir)
196
228
  end
197
229
 
198
- # Every other bluebook the target's `uses_framework` calls pulled into the registry, each
230
+ # Every other bluebook the target's `attaches` calls pulled into the registry, each
199
231
  # generated into its own module. A framework chapter is a function of its bluebook alone, so
200
232
  # regenerating one is safe whichever domain last touched it.
201
233
  def write_chapters
@@ -203,7 +235,7 @@ module Hecks
203
235
  policy_sources: [{ domain_name: @domain_name, policies: @ir[:policies],
204
236
  aggregates: @ir[:aggregates] }],
205
237
  mod_names: { @mod_name => @domain_name } }
206
- vendored = @registry.hecksagons.values.flat_map(&:vendored_bluebooks)
238
+ vendored = @registry.hecksagons.values.flat_map(&:vendored_packages)
207
239
  (@registry.bluebooks.keys - [@domain_name]).each { |chapter| write_chapter(chapter, vendored, totals) }
208
240
  totals
209
241
  end
@@ -213,7 +245,7 @@ module Hecks
213
245
  totals[:mod_names][mod_name] = chapter_name
214
246
  chapter_ir = json_shaped(Hecks::Projector::Exporter.call(@registry).fetch(chapter_name))
215
247
  result = nil
216
- RustProjection::WriteIfChanged.track_directory(File.join(@out_root, mod_name)) do
248
+ WriteIfChanged.track_directory(File.join(@out_root, mod_name)) do
217
249
  result = RustProjection::DomainGenerator.call(chapter_ir, "#{@domain} (#{attachment(chapter_name, vendored)})",
218
250
  File.join(@out_root, mod_name), mod_name,
219
251
  merged_module: false)
@@ -230,7 +262,7 @@ module Hecks
230
262
  # sibling hecksagon vendors in is labelled as vendored.
231
263
  def attachment(chapter_name, vendored)
232
264
  name = vendored.find { |candidate| Hecks::Naming.pascal(candidate.to_s) == chapter_name }
233
- name ? "uses_embryonaut_bluebook #{name.inspect}" : "uses_framework #{chapter_name.inspect}"
265
+ name ? "attaches #{name.inspect}, from: :vendor" : "attaches #{chapter_name.inspect}"
234
266
  end
235
267
 
236
268
  def reference_keys(target_aggregates, chapters)
@@ -243,7 +275,7 @@ module Hecks
243
275
  # Writes a module's `merged.rs`: the registry and every dispatch table over its aggregates.
244
276
  def write_merged(path, aggregates, queries, read_models, policies:, cross:, managers:, keys:)
245
277
  projector = RustProjection::Projector
246
- wrote = RustProjection::WriteIfChanged.block(path) do |file|
278
+ wrote = WriteIfChanged.block(path) do |file|
247
279
  [projector.emit_registry(aggregates), projector.emit_reference_lookup(aggregates), policies, cross,
248
280
  projector.emit_process_manager_table(managers), projector.emit_reference_key_table(keys),
249
281
  projector.emit_creates_table(aggregates), projector.emit_identity_head_table(aggregates),
@@ -280,7 +312,7 @@ module Hecks
280
312
  all_dirs = Dir.children(@out_root).select { |name| File.directory?(File.join(@out_root, name)) }.sort
281
313
  @domains = all_dirs.select { |name| File.exist?(File.join(@out_root, name, "merged.rs")) }
282
314
  path = File.join(@out_root, "mod.rs")
283
- wrote = RustProjection::WriteIfChanged.block(path) do |file|
315
+ wrote = WriteIfChanged.block(path) do |file|
284
316
  RootMod.new(file, all_dirs, @domains, @mod_name).write
285
317
  end
286
318
  puts(wrote ? "wrote #{path}" : "#{path} unchanged")
@@ -296,7 +328,7 @@ module Hecks
296
328
  @domains.each { |name| manifest = add_feature(manifest, name) }
297
329
  manifest = manifest.sub(/^default\s*=.*$/, "default = [#{@mod_name.inspect}]")
298
330
  # An unchanged manifest is not rewritten: a new mtime makes Cargo rebuild the whole crate.
299
- if RustProjection::WriteIfChanged.call(path, manifest)
331
+ if WriteIfChanged.call(path, manifest)
300
332
  puts "wrote #{path} (default feature: #{@mod_name})"
301
333
  else
302
334
  puts "#{path} unchanged (default feature already #{@mod_name})"
@@ -330,3 +362,4 @@ module Hecks
330
362
  end
331
363
 
332
364
  require_relative "project_rust/root_mod"
365
+ require_relative "project_rust/codegen_run"
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hecks
4
+ module RustBuild
5
+ # Rust string literals. Ruby's `String#inspect` escapes for Ruby's own read-back (a
6
+ # brace-less `\uXXXX`, for one), which is not valid Rust syntax.
7
+ module RustLiteral
8
+ # Escapes only what Rust's string-literal grammar needs: backslash, double quote and
9
+ # control characters.
10
+ #
11
+ # @param text [#to_s] the text to embed
12
+ # @return [String] the quoted literal
13
+ def self.string(text)
14
+ escaped = text.to_s.each_char.map { |char| escape(char) }.join
15
+ "\"#{escaped}\""
16
+ end
17
+
18
+ def self.escape(char)
19
+ case char
20
+ when "\\" then "\\\\"
21
+ when "\"" then "\\\""
22
+ when "\n" then "\\n"
23
+ when "\r" then "\\r"
24
+ when "\t" then "\\t"
25
+ else
26
+ code = char.ord
27
+ code < 0x20 || code == 0x7F ? format("\\u{%x}", code) : char
28
+ end
29
+ end
30
+ private_class_method :escape
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Hecks
6
+ module RustBuild
7
+ # Skips writes whose content is unchanged so file mtimes stay stable.
8
+ # A rewrite alone forces Cargo to rebuild downstream crates.
9
+ module WriteIfChanged
10
+ module_function
11
+
12
+ # Records every path written or confirmed unchanged inside the block, then deletes
13
+ # files left in `dir` that were never touched (orphans of removed aggregates).
14
+ # Every write is recorded against every open ancestor directory.
15
+ # `push_directory`/`pop_and_prune` are the same operation split for non-adjacent writes.
16
+ def track_directory(dir)
17
+ push_directory(dir)
18
+ yield
19
+ ensure
20
+ pop_and_prune(dir)
21
+ end
22
+
23
+ def push_directory(dir)
24
+ FileUtils.mkdir_p(dir)
25
+ stack.push([File.expand_path(dir), Set.new])
26
+ end
27
+
28
+ # Matches by directory, not LIFO: nested directories can close out of order.
29
+ def pop_and_prune(dir)
30
+ abs_dir = File.expand_path(dir)
31
+ idx = stack.rindex { |tracked_dir, _| tracked_dir == abs_dir }
32
+ return unless idx
33
+
34
+ _dir, touched = stack.delete_at(idx)
35
+ existing = Dir.glob(File.join(abs_dir, "*"))
36
+ (existing - touched.to_a).each do |orphan|
37
+ FileUtils.rm_rf(orphan)
38
+ puts "pruned #{orphan} (no longer generated)"
39
+ end
40
+ end
41
+
42
+ def stack
43
+ Thread.current[:hecks_write_if_changed_stack] ||= []
44
+ end
45
+ private_class_method :stack
46
+
47
+ def record_touch(path)
48
+ abs = File.expand_path(path)
49
+ stack.each do |dir, touched|
50
+ touched << abs if abs.start_with?("#{dir}/")
51
+ end
52
+ end
53
+ private_class_method :record_touch
54
+
55
+ # Writes `content` to `path` unless it already holds identical bytes.
56
+ # Returns whether it wrote.
57
+ def call(path, content)
58
+ record_touch(path)
59
+ # `.b` on both sides: String#== is encoding-aware, so UTF-8 vs ASCII-8BIT
60
+ # content can compare unequal even when the bytes are identical.
61
+ return false if File.exist?(path) && File.binread(path) == content.b
62
+
63
+ File.write(path, content)
64
+ true
65
+ end
66
+
67
+ # Like `call`, for callers that build content by writing to an IO. Yields a sibling
68
+ # temp file, then renames it into place only if the bytes differ. Returns whether it wrote.
69
+ def block(path, &)
70
+ record_touch(path)
71
+ tmp = "#{path}.tmp-#{Process.pid}-#{rand(1_000_000)}"
72
+ begin
73
+ File.open(tmp, "w", &)
74
+ if File.exist?(path) && FileUtils.compare_file(tmp, path)
75
+ false
76
+ else
77
+ FileUtils.mv(tmp, path)
78
+ true
79
+ end
80
+ ensure
81
+ FileUtils.rm_f(tmp)
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end