hecks 3.2.1 → 3.3.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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/bluebook/assembly/contracts.rb +2 -1
  3. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +1 -0
  4. data/lib/hecks/bluebook/dsl/command_builder.rb +2 -39
  5. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +1 -1
  6. data/lib/hecks/bluebook/dsl/need_word.rb +50 -0
  7. data/lib/hecks/bluebook/dsl/query_builder.rb +6 -1
  8. data/lib/hecks/bluebook/query.rb +8 -5
  9. data/lib/hecks/cli/interview_draft.rb +86 -31
  10. data/lib/hecks/cli/interview_session.rb +77 -26
  11. data/lib/hecks/cli/interview_shape.rb +179 -0
  12. data/lib/hecks/doors/cli_runner.rb +14 -1
  13. data/lib/hecks/doors/launcher_options.rb +13 -3
  14. data/lib/hecks/doors/mcp_door_scope.rb +2 -1
  15. data/lib/hecks/doors.rb +1 -1
  16. data/lib/hecks/embryonaut_bluebook/manifest.rb +117 -0
  17. data/lib/hecks/embryonaut_bluebook/registry.rb +209 -0
  18. data/lib/hecks/embryonaut_bluebook/vendor.rb +1 -1
  19. data/lib/hecks/embryonaut_bluebook.rb +2 -0
  20. data/lib/hecks/fuzzing/qa_settings.rb +15 -34
  21. data/lib/hecks/gate/stages.yml +113 -8
  22. data/lib/hecks/hecks/adapters/codebase/regeneration.rb +10 -1
  23. data/lib/hecks/hecks/adapters/first_admin.rb +108 -0
  24. data/lib/hecks/hecks/adapters/git.rb +59 -0
  25. data/lib/hecks/hecks/adapters/in_process_access.rb +25 -0
  26. data/lib/hecks/hecks/adapters/in_process_boot.rb +2 -0
  27. data/lib/hecks/hecks/adapters/rust_toolchain.rb +18 -3
  28. data/lib/hecks/hecks/adapters/site_toolchain.rb +67 -0
  29. data/lib/hecks/hecks/codebase.behaviors +6 -0
  30. data/lib/hecks/hecks/codebase.bluebook +21 -0
  31. data/lib/hecks/hecks/custodian.behaviors +6 -0
  32. data/lib/hecks/hecks/custodian.bluebook +217 -0
  33. data/lib/hecks/hecks/hecks.hecksagon +24 -0
  34. data/lib/hecks/hecks/hecks.world +8 -3
  35. data/lib/hecks/hecks/site.world +5 -2
  36. data/lib/hecks/language/bluebook/query.bluebook +25 -0
  37. data/lib/hecks/language/bluebook/vocabulary.bluebook +213 -0
  38. data/lib/hecks/language/hecksagon/hecksagon.bluebook +1 -1
  39. data/lib/hecks/language/oidc.json +5 -0
  40. data/lib/hecks/projections/deploy/box/hosting.rb +170 -0
  41. data/lib/hecks/projections/deploy/box/hosting_settings.rb +123 -0
  42. data/lib/hecks/projections/deploy/box/settings.rb +17 -4
  43. data/lib/hecks/projections/deploy/box/templates/deploy-service.services-roll.part +33 -0
  44. data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +79 -0
  45. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef-roll.part +77 -0
  46. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef.part +12 -0
  47. data/lib/hecks/projections/deploy/box/templates/hosting.mk.tmpl +24 -0
  48. data/lib/hecks/projections/deploy/box/templates/smoke-after-deploy.sh.tmpl +271 -0
  49. data/lib/hecks/projections/deploy/box.rb +15 -2
  50. data/lib/hecks/projections/deploy/fargate.rb +3 -3
  51. data/lib/hecks/projections/deploy/lambda.rb +8 -8
  52. data/lib/hecks/projections/model/deviations.rb +1 -1
  53. data/lib/hecks/projections/site/live_edge.rb +162 -0
  54. data/lib/hecks/quality_control/adapters/git_pr.rb +0 -14
  55. data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +1 -1
  56. data/lib/hecks/quality_control/cli/qa_open_pr.rb +32 -16
  57. data/lib/hecks/quality_control/cli/qa_seed_angles.rb +2 -2
  58. data/lib/hecks/quality_control/cli/qa_seed_targets.rb +1 -2
  59. data/lib/hecks/quality_control/quality_control.bluebook +76 -124
  60. data/lib/hecks/runtime/interpreting.rb +3 -22
  61. data/lib/hecks/runtime/needs.rb +48 -0
  62. data/lib/hecks/runtime/query_interpreter.rb +3 -0
  63. data/lib/hecks/runtime/registry/verification.rb +3 -3
  64. data/lib/hecks/rust_build/host.rb +147 -0
  65. data/lib/hecks/rust_build/kernel_input.rb +40 -18
  66. data/lib/hecks/rust_build/project_rust/codegen_run.rb +0 -18
  67. data/lib/hecks/rust_build/project_rust.rb +3 -10
  68. data/lib/hecks/rust_build.rb +1 -0
  69. data/lib/hecks/site/bluebook/site.bluebook +53 -1
  70. data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
  71. data/lib/hecks/sme/bluebook/sme.bluebook +128 -1
  72. data/lib/hecks/tools/gate.rb +1 -1
  73. data/lib/hecks/tools/rust_comment_style.rb +1 -3
  74. data/lib/hecks/tools/tools_doc.rb +204 -0
  75. data/lib/hecks/tools.rb +1 -0
  76. data/lib/hecks/version.rb +1 -1
  77. data/lib/hecks/vocabulary.rb +151 -0
  78. data/qa/settings.yml +1 -2
  79. data/rust/build/src/lineage_pass.rs +30 -15
  80. data/rust/build/src/main.rs +4 -2
  81. data/rust/build/src/pipeline.rs +8 -19
  82. data/rust/codegen/src/bridging.rs +7 -7
  83. data/rust/codegen/src/json.rs +214 -23
  84. data/rust/codegen/src/literal.rs +6 -3
  85. data/rust/codegen/src/main.rs +20 -9
  86. data/rust/codegen/src/naming.rs +4 -2
  87. data/rust/codegen/src/optional_pass.rs +226 -0
  88. data/rust/{build → codegen}/src/sidecars.rs +9 -5
  89. data/rust/host/HECKS_RELEASE +1 -1
  90. data/rust/host/src/api.rs +1 -1
  91. data/rust/host/src/dispatch.rs +5 -0
  92. data/rust/host/src/needs.rs +99 -2
  93. data/rust/host/src/resend.rs +4 -4
  94. data/rust/host/src/ui_schema.rs +1 -1
  95. data/rust/parser/src/emit.rs +12 -0
  96. data/rust/parser/src/ir.rs +2 -0
  97. data/rust/parser/src/keywords.rs +2 -0
  98. data/rust/parser/src/main.rs +1 -0
  99. data/rust/parser/src/parse/command.rs +2 -44
  100. data/rust/parser/src/parse/mod.rs +1 -0
  101. data/rust/parser/src/parse/needs.rs +60 -0
  102. data/rust/parser/src/parse/query.rs +3 -0
  103. data/rust/src/kernel/cli.rs +16 -6
  104. data/rust/src/kernel/needs.rs +104 -9
  105. metadata +22 -8
  106. data/lib/hecks/rust_build/append_optionals.rb +0 -79
  107. data/lib/hecks/rust_build/rust_literal.rb +0 -33
  108. data/lib/hecks/three_zero/forms.yml +0 -127
  109. data/lib/hecks/three_zero.rb +0 -12
  110. data/rust/build/src/json.rs +0 -421
  111. data/rust/build/src/optional_pass.rs +0 -150
@@ -119,25 +119,31 @@ fn world_opener_name(line: &str) -> Option<String> {
119
119
  Some(quoted[..quoted.find('"')?].to_string())
120
120
  }
121
121
 
122
+ // Where an adapter class can live: the driven adapters, and the era plugin that holds `PostgresEra`,
123
+ // the one adapter that declares itself lineage-capable.
124
+ const ADAPTER_DIRS: [&str; 2] = ["lib/hecks/adapters/driven", "lib/hecks/ports/persistence/plugins/era"];
125
+
122
126
  // Reads each adapter's own source file rather than invoking Ruby.
123
127
  fn lineage_capable_adapter_names(root: &Path) -> Result<Vec<String>, String> {
124
- let dir = root.join("lib/hecks/adapters/driven");
125
- let entries = std::fs::read_dir(&dir).map_err(|e| format!("reading {}: {e}", dir.display()))?;
126
-
127
128
  let mut names = Vec::new();
128
- for entry in entries {
129
- let entry = entry.map_err(|e| format!("reading {}: {e}", dir.display()))?;
130
- let path = entry.path();
131
- if path.extension().and_then(|e| e.to_str()) != Some("rb") {
132
- continue;
133
- }
129
+ for relative in ADAPTER_DIRS {
130
+ let dir = root.join(relative);
131
+ let entries = std::fs::read_dir(&dir).map_err(|e| format!("reading {}: {e}", dir.display()))?;
132
+
133
+ for entry in entries {
134
+ let entry = entry.map_err(|e| format!("reading {}: {e}", dir.display()))?;
135
+ let path = entry.path();
136
+ if path.extension().and_then(|e| e.to_str()) != Some("rb") {
137
+ continue;
138
+ }
134
139
 
135
- let text = std::fs::read_to_string(&path).map_err(|e| format!("reading {}: {e}", path.display()))?;
136
- if !declares_lineage_capable_true(&text) {
137
- continue;
138
- }
139
- if let Some(name) = top_level_class_name(&text) {
140
- names.push(name);
140
+ let text = std::fs::read_to_string(&path).map_err(|e| format!("reading {}: {e}", path.display()))?;
141
+ if !declares_lineage_capable_true(&text) {
142
+ continue;
143
+ }
144
+ if let Some(name) = top_level_class_name(&text) {
145
+ names.push(name);
146
+ }
141
147
  }
142
148
  }
143
149
 
@@ -278,6 +284,15 @@ mod tests {
278
284
  assert!(!declares_lineage_capable_true("no such method here\n"));
279
285
  }
280
286
 
287
+ // The adapter moved out of `lib/hecks/adapters/driven` once and the scan silently found none, which
288
+ // left every lineage-capable aggregate out of a Ruby-free build's `ir.json`.
289
+ #[test]
290
+ fn finds_postgres_era_in_the_real_source_tree() {
291
+ let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
292
+ let names = lineage_capable_adapter_names(&root).expect("scans the adapter directories");
293
+ assert!(names.iter().any(|name| name == "PostgresEra"), "lineage-capable adapters found: {names:?}");
294
+ }
295
+
281
296
  #[test]
282
297
  fn finds_the_top_level_class_name() {
283
298
  assert_eq!(top_level_class_name("module Hecks\n module Adapters\n class Postgres\n def x; end\n"), Some("Postgres".to_string()));
@@ -4,14 +4,16 @@
4
4
 
5
5
  mod build_artifact;
6
6
  mod cargo_sync;
7
+ // The one JSON implementation, compiled from `hecks-codegen`'s own source: neither crate depends on
8
+ // the other, and a packaged workspace ships both side by side. This crate reads only part of it.
9
+ #[allow(dead_code)]
10
+ #[path = "../../codegen/src/json.rs"]
7
11
  mod json;
8
12
  mod lineage_pass;
9
- mod optional_pass;
10
13
  mod pipeline;
11
14
  mod reserved_names;
12
15
  mod resolve;
13
16
  mod root;
14
- mod sidecars;
15
17
  mod subprocess;
16
18
  mod tmp;
17
19
 
@@ -7,9 +7,7 @@ use crate::build_artifact;
7
7
  use crate::cargo_sync;
8
8
  use crate::json::Json;
9
9
  use crate::lineage_pass;
10
- use crate::optional_pass;
11
10
  use crate::resolve;
12
- use crate::sidecars;
13
11
  use crate::subprocess;
14
12
  use crate::tmp::TempDir;
15
13
 
@@ -77,7 +75,7 @@ pub fn run(root: &Path, domain: &str, opts: &Options) -> Result<(), String> {
77
75
  if let Some(p) = &hecksagon_path {
78
76
  target_files.push(p.clone());
79
77
  }
80
- let target_ir_text = parse_chapter_with_optionals(&parser_bin, &target_chapter_name, &target_files)?;
78
+ let target_ir_text = parse_chapter(&parser_bin, &target_chapter_name, &target_files)?;
81
79
  // Only the target chapter gets a `lineage` key — a framework chapter or
82
80
  // `meta` never does, matching `derive_lineage` (Ruby) re-`JSON.parse`ing
83
81
  // rather than chaining a live object through both passes.
@@ -113,7 +111,7 @@ pub fn run(root: &Path, domain: &str, opts: &Options) -> Result<(), String> {
113
111
  fw_path.display()
114
112
  ));
115
113
  }
116
- let fw_ir_text = parse_chapter_with_optionals(&parser_bin, &fw_chapter_name, std::slice::from_ref(&fw_path))?;
114
+ let fw_ir_text = parse_chapter(&parser_bin, &fw_chapter_name, std::slice::from_ref(&fw_path))?;
117
115
  chapters.push(Chapter {
118
116
  mod_name: fw_name.to_lowercase(),
119
117
  source_label: format!("{domain} (attaches {fw_name:?})"),
@@ -141,7 +139,7 @@ pub fn run(root: &Path, domain: &str, opts: &Options) -> Result<(), String> {
141
139
  pkg_bluebooks.push(path.clone());
142
140
  }
143
141
  }
144
- let pkg_ir_text = parse_chapter_with_optionals(&parser_bin, &pkg_chapter_name, &pkg_bluebooks)?;
142
+ let pkg_ir_text = parse_chapter(&parser_bin, &pkg_chapter_name, &pkg_bluebooks)?;
145
143
  chapters.push(Chapter {
146
144
  // Derived from the declared chapter name (asserted equal to
147
145
  // `expected_chapter_name` above), not the raw `pkg_name` — that
@@ -172,7 +170,7 @@ pub fn run(root: &Path, domain: &str, opts: &Options) -> Result<(), String> {
172
170
  // The self-hosted language, compiled in too, under the same "Bluebook"
173
171
  // chapter name.
174
172
  let grammar_files = resolve::grammar_files(root)?;
175
- let meta_ir_text = parse_chapter_with_optionals(&parser_bin, "Bluebook", &grammar_files)?;
173
+ let meta_ir_text = parse_chapter(&parser_bin, "Bluebook", &grammar_files)?;
176
174
 
177
175
  let out_root = root.join("rust/src/generated");
178
176
  std::fs::create_dir_all(&out_root).map_err(|e| format!("creating {}: {e}", out_root.display()))?;
@@ -213,12 +211,6 @@ pub fn run(root: &Path, domain: &str, opts: &Options) -> Result<(), String> {
213
211
  }
214
212
  run_codegen_full(&codegen_bin, &codegen_args)?;
215
213
 
216
- sidecars::write(&out_root.join("meta"), &meta_ir_text, META_SOURCE_LABEL)?;
217
- sidecars::write(&out_root.join(&target_mod_name), &target_ir_text, domain)?;
218
- for c in &chapters {
219
- sidecars::write(&out_root.join(&c.mod_name), &c.ir_text, &c.source_label)?;
220
- }
221
-
222
214
  let cargo_toml_path = root.join("rust/Cargo.toml");
223
215
  cargo_sync::run(&out_root, &cargo_toml_path, &target_mod_name)?;
224
216
 
@@ -233,18 +225,15 @@ pub fn run(root: &Path, domain: &str, opts: &Options) -> Result<(), String> {
233
225
  Ok(())
234
226
  }
235
227
 
236
- // Parses via `hecks-parse chapter`, then runs `optional_pass::run` and
237
- // re-serializes — matching `derive_append_optionals` (Ruby).
238
- fn parse_chapter_with_optionals(parser_bin: &Path, chapter_name: &str, files: &[PathBuf]) -> Result<String, String> {
228
+ // Parses via `hecks-parse chapter`. `hecks-codegen` marks the IR and writes its `ir.json` and
229
+ // `metadata.rs` itself, so the text goes through as `hecks-parse` printed it.
230
+ fn parse_chapter(parser_bin: &Path, chapter_name: &str, files: &[PathBuf]) -> Result<String, String> {
239
231
  let parser_bin_str = parser_bin.to_string_lossy().to_string();
240
232
  let mut args: Vec<String> = vec!["chapter".to_string(), "--chapter".to_string(), chapter_name.to_string()];
241
233
  args.extend(files.iter().map(|p| p.to_string_lossy().to_string()));
242
234
  let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
243
235
 
244
- let stdout = subprocess::run_capture(&parser_bin_str, &arg_refs)?;
245
- let mut ir = Json::parse(&stdout).map_err(|e| format!("parsing hecks-parse chapter output for {chapter_name}: {e}"))?;
246
- optional_pass::run(&mut ir);
247
- Ok(crate::json::write(&ir))
236
+ subprocess::run_capture(&parser_bin_str, &arg_refs)
248
237
  }
249
238
 
250
239
  fn run_codegen_full(codegen_bin: &Path, args: &[String]) -> Result<(), String> {
@@ -1,6 +1,6 @@
1
1
  //! Port of the retired Ruby generator's `bridging.rb`: cross-type coercion checks for `:set` and `:append` RHS.
2
2
 
3
- use crate::json::Json;
3
+ use crate::json::{Json, Number};
4
4
  use crate::literal::Literal;
5
5
  use crate::naming;
6
6
  use std::collections::{HashMap, HashSet};
@@ -312,15 +312,15 @@ pub fn arithmetic_amount_expr(source: &Json, command: &Json, value_objects_by_na
312
312
  let kind = source.get("kind").map(Json::to_s).unwrap_or_default();
313
313
  if kind == "literal" {
314
314
  let value = source.get("value")?;
315
- if let Json::Int(_) = value {
315
+ if matches!(value.number(), Some(Number::Int(_))) {
316
316
  return Some(naming::literal_rhs(value));
317
317
  }
318
318
  if !matches!(value, Json::Object(_)) {
319
319
  return None;
320
320
  }
321
321
  let key_value = value.get_raw(target_integer_field)?;
322
- return match key_value {
323
- Json::Int(_) => Some(naming::literal_rhs(key_value)),
322
+ return match key_value.number() {
323
+ Some(Number::Int(_)) => Some(naming::literal_rhs(key_value)),
324
324
  _ => None,
325
325
  };
326
326
  }
@@ -347,9 +347,9 @@ pub fn clamp_bounds_ints(source: &Json) -> Option<(i64, i64)> {
347
347
  if items.len() != 2 {
348
348
  return None;
349
349
  }
350
- let Json::Int(min) = &items[0] else { return None };
351
- let Json::Int(max) = &items[1] else { return None };
352
- Some((*min, *max))
350
+ let Some(Number::Int(min)) = items[0].number() else { return None };
351
+ let Some(Number::Int(max)) = items[1].number() else { return None };
352
+ Some((min, max))
353
353
  }
354
354
 
355
355
  /// An aggregate attribute a creating command's arguments never mention.
@@ -1,21 +1,31 @@
1
- //! A minimal, dependency-free JSON reader indexed by string key, like the Ruby `ir[:key]` Hash.
2
- //! It is read-only: this crate emits Rust source, never JSON.
1
+ //! A minimal, dependency-free, mutable JSON value indexed by string key, like the Ruby `ir[:key]`
2
+ //! Hash, with a reader and a pretty writer. The writer matches `JSON.pretty_generate`
3
+ //! byte-for-byte, including its empty-array quirk.
3
4
 
4
5
  use std::fmt;
6
+ use std::fmt::Write as _;
5
7
 
6
8
  #[derive(Debug, Clone, PartialEq)]
7
9
  pub enum Json {
8
10
  Null,
9
11
  Bool(bool),
10
- // Kept apart from Float: a whole-number Float default (`0.0`) must not render as `0`.
11
- Int(i64),
12
- Float(f64),
12
+ /// Raw literal text as scanned, so numbers round-trip unchanged; `number` reads it as Ruby wrote it.
13
+ Number(String),
13
14
  String(String),
14
15
  Array(Vec<Json>),
15
- // Insertion-ordered pairs, matching the declaration order of the Ruby Hash.
16
+ /// Insertion-ordered pairs, matching the declaration order of the Ruby Hash; byte-exact
17
+ /// re-emission depends on key order.
16
18
  Object(Vec<(String, Json)>),
17
19
  }
18
20
 
21
+ /// A `Json::Number` as Ruby wrote it: a `.` or an exponent means a Float, anything else an
22
+ /// Integer. Kept apart: a whole-number Float default (`0.0`) must not render as `0`.
23
+ #[derive(Debug, Clone, Copy, PartialEq)]
24
+ pub enum Number {
25
+ Int(i64),
26
+ Float(f64),
27
+ }
28
+
19
29
  impl Json {
20
30
  pub fn parse(text: &str) -> Result<Json, String> {
21
31
  let mut p = Parser { bytes: text.as_bytes(), pos: 0 };
@@ -44,6 +54,13 @@ impl Json {
44
54
  }
45
55
  }
46
56
 
57
+ pub fn get_mut(&mut self, key: &str) -> Option<&mut Json> {
58
+ match self {
59
+ Json::Object(pairs) => pairs.iter_mut().find(|(k, _)| k == key).map(|(_, v)| v),
60
+ _ => None,
61
+ }
62
+ }
63
+
47
64
  pub fn as_str(&self) -> Option<&str> {
48
65
  match self {
49
66
  Json::String(s) => Some(s.as_str()),
@@ -58,25 +75,50 @@ impl Json {
58
75
  }
59
76
  }
60
77
 
78
+ pub fn as_array_mut(&mut self) -> Option<&mut Vec<Json>> {
79
+ match self {
80
+ Json::Array(items) => Some(items),
81
+ _ => None,
82
+ }
83
+ }
84
+
85
+ pub fn as_object(&self) -> Option<&[(String, Json)]> {
86
+ match self {
87
+ Json::Object(pairs) => Some(pairs.as_slice()),
88
+ _ => None,
89
+ }
90
+ }
91
+
61
92
  pub fn as_bool(&self) -> bool {
62
93
  // Ruby truthiness: only `false` and `nil` are falsy, so `0` and `""` are truthy.
63
94
  !matches!(self, Json::Bool(false) | Json::Null)
64
95
  }
65
96
 
66
- pub fn as_f64(&self) -> Option<f64> {
67
- match self {
68
- Json::Int(n) => Some(*n as f64),
69
- Json::Float(n) => Some(*n),
70
- _ => None,
97
+ /// The number as Ruby wrote it, or `None` for any other value. An integer too large for `i64`
98
+ /// reads as a Float.
99
+ pub fn number(&self) -> Option<Number> {
100
+ let Json::Number(text) = self else { return None };
101
+ if text.contains(|c| matches!(c, '.' | 'e' | 'E')) {
102
+ return text.parse::<f64>().ok().map(Number::Float);
71
103
  }
104
+ match text.parse::<i64>() {
105
+ Ok(n) => Some(Number::Int(n)),
106
+ Err(_) => text.parse::<f64>().ok().map(Number::Float),
107
+ }
108
+ }
109
+
110
+ pub fn as_f64(&self) -> Option<f64> {
111
+ self.number().map(|n| match n {
112
+ Number::Int(n) => n as f64,
113
+ Number::Float(n) => n,
114
+ })
72
115
  }
73
116
 
74
117
  pub fn as_i64(&self) -> Option<i64> {
75
- match self {
76
- Json::Int(n) => Some(*n),
77
- Json::Float(n) => Some(*n as i64),
78
- _ => None,
79
- }
118
+ self.number().map(|n| match n {
119
+ Number::Int(n) => n,
120
+ Number::Float(n) => n as i64,
121
+ })
80
122
  }
81
123
 
82
124
  /// `#to_s` on a scalar; non-scalars render as the empty string rather than panicking.
@@ -84,8 +126,11 @@ impl Json {
84
126
  match self {
85
127
  Json::Null => String::new(),
86
128
  Json::Bool(b) => b.to_string(),
87
- Json::Int(n) => n.to_string(),
88
- Json::Float(n) => format_number(*n),
129
+ Json::Number(text) => match self.number() {
130
+ Some(Number::Int(n)) => n.to_string(),
131
+ Some(Number::Float(n)) => format_number(n),
132
+ None => text.clone(),
133
+ },
89
134
  Json::String(s) => s.clone(),
90
135
  _ => String::new(),
91
136
  }
@@ -95,6 +140,21 @@ impl Json {
95
140
  pub fn each(&self) -> &[Json] {
96
141
  self.as_array().unwrap_or(&[])
97
142
  }
143
+
144
+ /// Sets `key` to `value`, overwriting in place or else appending last.
145
+ pub fn set(&mut self, key: &str, value: Json) {
146
+ if let Json::Object(pairs) = self {
147
+ if let Some((_, existing)) = pairs.iter_mut().find(|(k, _)| k == key) {
148
+ *existing = value;
149
+ return;
150
+ }
151
+ pairs.push((key.to_string(), value));
152
+ }
153
+ }
154
+
155
+ pub fn set_bool(&mut self, key: &str, value: bool) {
156
+ self.set(key, Json::Bool(value));
157
+ }
98
158
  }
99
159
 
100
160
  impl fmt::Display for Json {
@@ -301,6 +361,7 @@ impl<'a> Parser<'a> {
301
361
  Ok(out)
302
362
  }
303
363
 
364
+ // Keeps the raw literal text, so numbers round-trip unchanged.
304
365
  fn parse_number(&mut self) -> Result<Json, String> {
305
366
  let start = self.pos;
306
367
  if self.peek() == Some(b'-') {
@@ -325,11 +386,141 @@ impl<'a> Parser<'a> {
325
386
  }
326
387
  }
327
388
  let text = std::str::from_utf8(&self.bytes[start..self.pos]).map_err(|e| e.to_string())?;
328
- // A `.` or exponent means Ruby wrote a Float; otherwise an Integer (see `Json::Int`).
329
- if text.contains('.') || text.contains('e') || text.contains('E') {
330
- text.parse::<f64>().map(Json::Float).map_err(|e| e.to_string())
331
- } else {
332
- text.parse::<i64>().map(Json::Int).map_err(|e| e.to_string())
389
+ Ok(Json::Number(text.to_string()))
390
+ }
391
+ }
392
+
393
+ /// Renders `value` exactly as `JSON.pretty_generate` does for the pinned json gem.
394
+ pub fn write(value: &Json) -> String {
395
+ let mut out = String::new();
396
+ write_value(&mut out, value, 0);
397
+ out
398
+ }
399
+
400
+ fn indent(out: &mut String, depth: usize) {
401
+ for _ in 0..depth {
402
+ out.push_str(" ");
403
+ }
404
+ }
405
+
406
+ fn write_value(out: &mut String, value: &Json, depth: usize) {
407
+ match value {
408
+ Json::Null => out.push_str("null"),
409
+ Json::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
410
+ Json::Number(n) => out.push_str(n),
411
+ Json::String(s) => write_string(out, s),
412
+ Json::Array(items) => write_array(out, items, depth),
413
+ Json::Object(pairs) => write_object(out, pairs, depth),
414
+ }
415
+ }
416
+
417
+ fn write_string(out: &mut String, text: &str) {
418
+ out.push('"');
419
+ for ch in text.chars() {
420
+ match ch {
421
+ '"' => out.push_str("\\\""),
422
+ '\\' => out.push_str("\\\\"),
423
+ '\n' => out.push_str("\\n"),
424
+ '\t' => out.push_str("\\t"),
425
+ '\r' => out.push_str("\\r"),
426
+ c if (c as u32) < 0x20 => {
427
+ let _ = write!(out, "\\u{:04x}", c as u32);
428
+ }
429
+ c => out.push(c),
333
430
  }
334
431
  }
432
+ out.push('"');
433
+ }
434
+
435
+ fn write_array(out: &mut String, items: &[Json], depth: usize) {
436
+ if items.is_empty() {
437
+ // Empty arrays render with a blank line, matching the pinned json gem.
438
+ out.push_str("[\n\n");
439
+ indent(out, depth);
440
+ out.push(']');
441
+ return;
442
+ }
443
+ out.push_str("[\n");
444
+ for (idx, item) in items.iter().enumerate() {
445
+ indent(out, depth + 1);
446
+ write_value(out, item, depth + 1);
447
+ if idx + 1 < items.len() {
448
+ out.push(',');
449
+ }
450
+ out.push('\n');
451
+ }
452
+ indent(out, depth);
453
+ out.push(']');
454
+ }
455
+
456
+ fn write_object(out: &mut String, pairs: &[(String, Json)], depth: usize) {
457
+ // An empty object needs no special case: the loop runs zero times.
458
+ out.push_str("{\n");
459
+ for (idx, (key, val)) in pairs.iter().enumerate() {
460
+ indent(out, depth + 1);
461
+ write_string(out, key);
462
+ out.push_str(": ");
463
+ write_value(out, val, depth + 1);
464
+ if idx + 1 < pairs.len() {
465
+ out.push(',');
466
+ }
467
+ out.push('\n');
468
+ }
469
+ indent(out, depth);
470
+ out.push('}');
471
+ }
472
+
473
+ #[cfg(test)]
474
+ mod tests {
475
+ use super::*;
476
+
477
+ #[test]
478
+ fn round_trips_an_empty_object() {
479
+ let text = "{\n}";
480
+ let value = Json::parse(text).expect("parses");
481
+ assert_eq!(write(&value), text);
482
+ }
483
+
484
+ #[test]
485
+ fn set_bool_overwrites_an_existing_key() {
486
+ let mut value = Json::parse("{\n \"optional\": false\n}").expect("parses");
487
+ value.set_bool("optional", true);
488
+ assert_eq!(write(&value), "{\n \"optional\": true\n}");
489
+ }
490
+
491
+ // A whole-number Float default must keep its `.0`, in the text and when rendered.
492
+ #[test]
493
+ fn keeps_a_float_apart_from_an_integer() {
494
+ let value = Json::parse("[0.0, 0, 1e3, -7, 12345678901234567890]").expect("parses");
495
+ let numbers: Vec<Option<Number>> = value.each().iter().map(Json::number).collect();
496
+
497
+ assert_eq!(numbers[0], Some(Number::Float(0.0)));
498
+ assert_eq!(numbers[1], Some(Number::Int(0)));
499
+ assert_eq!(numbers[2], Some(Number::Float(1000.0)));
500
+ assert_eq!(numbers[3], Some(Number::Int(-7)));
501
+ assert!(matches!(numbers[4], Some(Number::Float(_))), "an integer past i64 reads as a Float");
502
+ assert_eq!(value.each()[0].to_s(), "0.0");
503
+ assert_eq!(value.each()[1].to_s(), "0");
504
+ }
505
+
506
+ #[test]
507
+ fn writes_numbers_as_scanned() {
508
+ let text = "[\n 0.0,\n 1e3,\n -7\n]";
509
+ assert_eq!(write(&Json::parse(text).expect("parses")), text);
510
+ }
511
+
512
+ #[test]
513
+ fn reads_as_i64_and_f64_across_both_kinds() {
514
+ let value = Json::parse("[3, 2.5]").expect("parses");
515
+ assert_eq!(value.each()[0].as_f64(), Some(3.0));
516
+ assert_eq!(value.each()[1].as_i64(), Some(2));
517
+ assert_eq!(Json::String("3".to_string()).as_i64(), None);
518
+ }
519
+
520
+ #[test]
521
+ fn get_treats_null_as_missing_and_get_raw_does_not() {
522
+ let value = Json::parse("{\"a\": null}").expect("parses");
523
+ assert!(value.get("a").is_none());
524
+ assert_eq!(value.get_raw("a"), Some(&Json::Null));
525
+ }
335
526
  }
@@ -1,7 +1,7 @@
1
1
  //! Port of `Literal.read` from `lib/hecks/literal.rb`, the inverse of `Literal.render`.
2
2
  //! `Literal::from_json` covers values embedded raw in ir.json, which were never rendered.
3
3
 
4
- use crate::json::Json;
4
+ use crate::json::{Json, Number};
5
5
 
6
6
  #[derive(Debug, Clone, PartialEq)]
7
7
  pub enum Literal {
@@ -22,8 +22,11 @@ impl Literal {
22
22
  match value {
23
23
  Json::Null => Literal::Nil,
24
24
  Json::Bool(b) => Literal::Bool(*b),
25
- Json::Int(n) => Literal::Int(*n),
26
- Json::Float(n) => Literal::Float(*n),
25
+ Json::Number(_) => match value.number() {
26
+ Some(Number::Int(n)) => Literal::Int(n),
27
+ Some(Number::Float(n)) => Literal::Float(n),
28
+ None => Literal::Nil,
29
+ },
27
30
  Json::String(s) => Literal::Str(s.clone()),
28
31
  Json::Array(items) => Literal::Array(items.iter().map(Literal::from_json).collect()),
29
32
  Json::Object(pairs) => Literal::Hash(pairs.iter().map(|(k, v)| (k.clone(), Literal::from_json(v))).collect()),
@@ -19,6 +19,7 @@ mod literal;
19
19
  mod manifest;
20
20
  mod mutations;
21
21
  mod naming;
22
+ mod optional_pass;
22
23
  mod ports;
23
24
  mod prelude;
24
25
  mod queries;
@@ -28,6 +29,7 @@ mod reference_specs;
28
29
  mod registry;
29
30
  mod reserved_names;
30
31
  mod shared;
32
+ mod sidecars;
31
33
  mod skip_reason;
32
34
  mod types;
33
35
 
@@ -85,22 +87,29 @@ fn run_prelude(args: &[String]) -> Result<(), String> {
85
87
  }
86
88
 
87
89
  /// `hecks-codegen domain <ir.json> <source_label> <mod_name> <out_dir>` — writes one chapter's
88
- /// `<aggregate>.rs`, `registry.rs`, `mod.rs` and `manifest.json` into `out_dir`.
89
- ///
90
- /// `metadata.rs` and `ir.json` are left to `hecks project_rust`.
90
+ /// `<aggregate>.rs`, `registry.rs`, `mod.rs`, `manifest.json` and its `ir.json`/`metadata.rs`
91
+ /// sidecars into `out_dir`.
91
92
  fn run_domain(args: &[String]) -> Result<(), String> {
92
93
  let [ir_path, source_label, mod_name, out_dir] = args else {
93
94
  return Err("usage: hecks-codegen domain <ir.json> <source_label> <mod_name> <out_dir>".to_string());
94
95
  };
95
96
 
96
- let text = std::fs::read_to_string(ir_path).map_err(|e| format!("reading {ir_path}: {e}"))?;
97
- let ir = Json::parse(&text).map_err(|e| format!("parsing {ir_path}: {e}"))?;
97
+ let ir = read_ir(ir_path)?;
98
98
 
99
99
  let ex = exemplar::Exemplar::load();
100
100
  write_domain(&ex, &ir, source_label, mod_name, out_dir)?;
101
101
  Ok(())
102
102
  }
103
103
 
104
+ /// Reads one chapter's IR and prepares it for generation: marks the fields an `append` binds to an
105
+ /// optional argument. What it generates from and what it writes to `ir.json` are this same IR.
106
+ fn read_ir(path: &str) -> Result<Json, String> {
107
+ let text = std::fs::read_to_string(path).map_err(|e| format!("reading {path}: {e}"))?;
108
+ let mut ir = Json::parse(&text).map_err(|e| format!("parsing {path}: {e}"))?;
109
+ optional_pass::run(&mut ir);
110
+ Ok(ir)
111
+ }
112
+
104
113
  /// Writes one chapter's files into `out_dir` and returns the `GeneratedDomain`, so `run_full`
105
114
  /// can union the registry, query and read-model definitions across chapters.
106
115
  fn write_domain(
@@ -147,6 +156,10 @@ fn write_domain(
147
156
  std::fs::write(&manifest_path, &generated.manifest_json).map_err(|e| format!("writing {manifest_path}: {e}"))?;
148
157
  println!("wrote {manifest_path}");
149
158
 
159
+ let (ir_json_path, metadata_path) = sidecars::write(std::path::Path::new(out_dir), ir, source_label)?;
160
+ println!("wrote {}", ir_json_path.display());
161
+ println!("wrote {}", metadata_path.display());
162
+
150
163
  Ok(generated)
151
164
  }
152
165
 
@@ -181,8 +194,7 @@ fn run_full(args: &[String]) -> Result<(), String> {
181
194
 
182
195
  let ex = exemplar::Exemplar::load();
183
196
 
184
- let target_text = std::fs::read_to_string(target_ir_path).map_err(|e| format!("reading {target_ir_path}: {e}"))?;
185
- let target_ir = Json::parse(&target_text).map_err(|e| format!("parsing {target_ir_path}: {e}"))?;
197
+ let target_ir = read_ir(target_ir_path)?;
186
198
  let target_domain_name = target_ir.get("name").and_then(Json::as_str).unwrap_or("").to_string();
187
199
 
188
200
  let target_out_dir = format!("{out_root}/{target_mod_name}");
@@ -209,8 +221,7 @@ fn run_full(args: &[String]) -> Result<(), String> {
209
221
  let chapter_ir_path = &args[i + 2];
210
222
  i += 3;
211
223
 
212
- let text = std::fs::read_to_string(chapter_ir_path).map_err(|e| format!("reading {chapter_ir_path}: {e}"))?;
213
- let chapter_ir = Json::parse(&text).map_err(|e| format!("parsing {chapter_ir_path}: {e}"))?;
224
+ let chapter_ir = read_ir(chapter_ir_path)?;
214
225
  let chapter_domain_name = chapter_ir.get("name").and_then(Json::as_str).unwrap_or("").to_string();
215
226
 
216
227
  let chapter_out_dir = format!("{out_root}/{chapter_mod_name}");
@@ -285,8 +285,10 @@ pub fn literal_rhs(literal: &crate::json::Json) -> String {
285
285
  match literal {
286
286
  crate::json::Json::String(s) => format!("{}.to_string()", ruby_inspect_string(s)),
287
287
  // Separate arms so a whole-number Float renders as `0.0`, not an integer-typed `0`.
288
- crate::json::Json::Int(n) => n.to_string(),
289
- crate::json::Json::Float(_) => literal.to_s(),
288
+ crate::json::Json::Number(_) => match literal.number() {
289
+ Some(crate::json::Number::Int(n)) => n.to_string(),
290
+ _ => literal.to_s(),
291
+ },
290
292
  crate::json::Json::Bool(b) => b.to_string(),
291
293
  other => panic!("unsupported literal mutation source {other:?} — not one of String/Integer/Float/Boolean"),
292
294
  }