hecks 2.6.0 → 2.8.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 (216) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +13 -0
  3. data/lib/hecks/adapters/driven/google_authentication.rb +1 -1
  4. data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
  5. data/lib/hecks/adapters/driven/memory.rb +1 -1
  6. data/lib/hecks/adapters/driven/postgres.rb +1 -1
  7. data/lib/hecks/adapters/driven/prism.rb +2 -2
  8. data/lib/hecks/adapters/driven/sqlite/projection.rb +1 -1
  9. data/lib/hecks/adapters/driven/tenant_provisioner.rb +31 -10
  10. data/lib/hecks/behaviors/expectations.rb +2 -2
  11. data/lib/hecks/bluebook/assembly/contract.rb +1 -1
  12. data/lib/hecks/bluebook/assembly/contracts.rb +5 -5
  13. data/lib/hecks/bluebook/assembly/specializer.rb +2 -2
  14. data/lib/hecks/bluebook/assembly.rb +1 -1
  15. data/lib/hecks/bluebook/behaviour/chapter.rb +5 -2
  16. data/lib/hecks/bluebook/behaviour/read_model.rb +18 -0
  17. data/lib/hecks/bluebook/capabilities.rb +2 -2
  18. data/lib/hecks/bluebook/chapter.rb +1 -1
  19. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +2 -2
  20. data/lib/hecks/bluebook/dsl/binding_proxy.rb +6 -1
  21. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +2 -2
  22. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -0
  23. data/lib/hecks/bluebook/dsl/command_builder.rb +3 -3
  24. data/lib/hecks/bluebook/dsl/entity_builder.rb +1 -1
  25. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +1 -1
  26. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +14 -9
  27. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +1 -1
  28. data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -3
  29. data/lib/hecks/bluebook/dsl/translation_builder.rb +1 -1
  30. data/lib/hecks/bluebook/dsl/world_builder.rb +47 -3
  31. data/lib/hecks/bluebook/hexagon.rb +15 -7
  32. data/lib/hecks/bluebook/meta_validator/judge.rb +3 -3
  33. data/lib/hecks/bluebook/meta_validator/plan.rb +4 -4
  34. data/lib/hecks/bluebook/meta_validator/readings.rb +1 -1
  35. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +2 -2
  36. data/lib/hecks/bluebook/meta_validator/shapes.rb +1 -1
  37. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +11 -10
  38. data/lib/hecks/bluebook/meta_validator/world_judge.rb +3 -1
  39. data/lib/hecks/bluebook/meta_validator.rb +1 -1
  40. data/lib/hecks/bluebook/model_check/client_profile.rb +4 -70
  41. data/lib/hecks/bluebook/model_check.rb +13 -14
  42. data/lib/hecks/cache_dir.rb +103 -0
  43. data/lib/hecks/cli/document.rb +104 -0
  44. data/lib/hecks/cli/ir.rb +47 -0
  45. data/lib/hecks/cli/mcp.rb +32 -0
  46. data/lib/hecks/cli/mcp_door.rb +461 -0
  47. data/lib/hecks/cli/model_check.rb +270 -0
  48. data/lib/hecks/cli/project_cli.rb +123 -0
  49. data/lib/hecks/cli/project_diagrams.rb +43 -0
  50. data/lib/hecks/cli/run.rb +219 -0
  51. data/lib/hecks/cli/smoke_test.rb +59 -0
  52. data/lib/hecks/cli/stores.rb +54 -0
  53. data/lib/hecks/cli.rb +166 -0
  54. data/lib/hecks/embryonaut_bluebook/lock.rb +72 -0
  55. data/lib/hecks/embryonaut_bluebook/shape.rb +47 -0
  56. data/lib/hecks/embryonaut_bluebook/vendor.rb +194 -0
  57. data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +88 -0
  58. data/lib/hecks/embryonaut_bluebook.rb +29 -3
  59. data/lib/hecks/facade/handle.rb +2 -2
  60. data/lib/hecks/facade/surface/aggregate_door.rb +1 -1
  61. data/lib/hecks/forms/value_object_shape.rb +1 -1
  62. data/lib/hecks/framework/bluebook/console_settings.bluebook +3 -3
  63. data/lib/hecks/language/bluebook/vocabulary.bluebook +16 -0
  64. data/lib/hecks/language/world/world.bluebook +32 -2
  65. data/lib/hecks/mcp_door_scope.rb +176 -0
  66. data/lib/hecks/ports/key_vault.rb +1 -1
  67. data/lib/hecks/ports/persistence/append_only.rb +1 -1
  68. data/lib/hecks/ports/persistence/binding_policy.rb +16 -5
  69. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -2
  70. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +2 -2
  71. data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +2 -2
  72. data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +132 -0
  73. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +1 -1
  74. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +2 -2
  75. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +1 -1
  76. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +1 -1
  77. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +13 -7
  78. data/lib/hecks/ports/persistence/plugins/era.rb +7 -4
  79. data/lib/hecks/ports/persistence/postgres_dump/connection.rb +73 -0
  80. data/lib/hecks/ports/persistence/postgres_dump.rb +125 -0
  81. data/lib/hecks/ports/persistence/repository_factory.rb +2 -2
  82. data/lib/hecks/ports/persistence/state_codec.rb +1 -1
  83. data/lib/hecks/ports/persistence.rb +4 -0
  84. data/lib/hecks/projections/deploy/fargate/assembly.rb +193 -0
  85. data/lib/hecks/projections/deploy/fargate/cdn.rb +318 -0
  86. data/lib/hecks/projections/deploy/fargate/cdn_default.rb +87 -0
  87. data/lib/hecks/projections/deploy/fargate/check.rb +172 -0
  88. data/lib/hecks/projections/deploy/fargate/containers.rb +412 -0
  89. data/lib/hecks/projections/deploy/fargate/extras.rb +268 -0
  90. data/lib/hecks/projections/deploy/fargate/monitoring.rb +448 -0
  91. data/lib/hecks/projections/deploy/fargate/settings.rb +235 -0
  92. data/lib/hecks/projections/deploy/fargate/yaml.rb +116 -0
  93. data/lib/hecks/projections/deploy/fargate.rb +188 -139
  94. data/lib/hecks/projections/deploy/lambda.rb +8 -8
  95. data/lib/hecks/projections/deploy/preview/access_yaml.rb +145 -0
  96. data/lib/hecks/projections/deploy/preview/containers.rb +218 -0
  97. data/lib/hecks/projections/deploy/preview/preview.sh.erb +446 -0
  98. data/lib/hecks/projections/deploy/preview/routing_yaml.rb +216 -0
  99. data/lib/hecks/projections/deploy/preview/script.rb +40 -0
  100. data/lib/hecks/projections/deploy/preview/settings.rb +140 -0
  101. data/lib/hecks/projections/deploy/preview/task_yaml.rb +215 -0
  102. data/lib/hecks/projections/deploy/preview/template.rb +247 -0
  103. data/lib/hecks/projections/deploy/preview/yaml_text.rb +48 -0
  104. data/lib/hecks/projections/deploy/preview.rb +103 -0
  105. data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +182 -0
  106. data/lib/hecks/projections/deploy/scripts/expected-era.header +17 -0
  107. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +54 -0
  108. data/lib/hecks/projections/deploy/scripts/settings.rb +114 -0
  109. data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +232 -0
  110. data/lib/hecks/projections/deploy/scripts.rb +171 -0
  111. data/lib/hecks/projections/deploy/smoke/harness.js +366 -0
  112. data/lib/hecks/projections/deploy/smoke/workflow.yml.tmpl +56 -0
  113. data/lib/hecks/projections/deploy/smoke.rb +159 -0
  114. data/lib/hecks/projections/deploy/template_diff/comparer.rb +231 -0
  115. data/lib/hecks/projections/deploy/template_diff/loader.rb +105 -0
  116. data/lib/hecks/projections/deploy/template_diff.rb +173 -0
  117. data/lib/hecks/projections/glossary/sensitivity.rb +1 -1
  118. data/lib/hecks/projections/reference.rb +7 -1
  119. data/lib/hecks/projections.rb +2 -0
  120. data/lib/hecks/projector/exporter.rb +4 -4
  121. data/lib/hecks/projector/target.rb +1 -1
  122. data/lib/hecks/release/gem_pin/published_versions.rb +58 -0
  123. data/lib/hecks/release/gem_pin.rb +112 -0
  124. data/lib/hecks/release/runner/ci_publisher.rb +96 -0
  125. data/lib/hecks/release/runner/commands.rb +68 -0
  126. data/lib/hecks/release/runner/console.rb +52 -0
  127. data/lib/hecks/release/runner/gem_publisher.rb +44 -0
  128. data/lib/hecks/release/runner/git.rb +48 -0
  129. data/lib/hecks/release/runner/npm_publisher.rb +89 -0
  130. data/lib/hecks/release/runner/preflight.rb +119 -0
  131. data/lib/hecks/release/runner/published.rb +55 -0
  132. data/lib/hecks/release/runner/tagger.rb +87 -0
  133. data/lib/hecks/release/runner.rb +244 -0
  134. data/lib/hecks/router/namespace_installer.rb +1 -1
  135. data/lib/hecks/runtime/command_interpreter.rb +3 -3
  136. data/lib/hecks/runtime/command_rules/admissibility.rb +2 -2
  137. data/lib/hecks/runtime/command_rules/arithmetic.rb +2 -2
  138. data/lib/hecks/runtime/entity_element.rb +1 -1
  139. data/lib/hecks/runtime/entity_interpreter.rb +1 -1
  140. data/lib/hecks/runtime/invocation.rb +1 -1
  141. data/lib/hecks/runtime/loader.rb +27 -3
  142. data/lib/hecks/runtime/outbox.rb +1 -1
  143. data/lib/hecks/runtime/reaction_invocation.rb +3 -3
  144. data/lib/hecks/runtime/read_model_interpreter.rb +37 -15
  145. data/lib/hecks/runtime/reference_hop.rb +4 -4
  146. data/lib/hecks/runtime/registry/saga_persistence.rb +8 -2
  147. data/lib/hecks/runtime/registry/verification.rb +15 -13
  148. data/lib/hecks/runtime/registry/world_defaults.rb +106 -0
  149. data/lib/hecks/runtime/registry.rb +22 -13
  150. data/lib/hecks/runtime/saga_interpreter.rb +1 -1
  151. data/lib/hecks/runtime/tenant_check.rb +1 -1
  152. data/lib/hecks/runtime/value/coercion.rb +3 -3
  153. data/lib/hecks/runtime/value/entity_list_coercion.rb +1 -1
  154. data/lib/hecks/storehouse.rb +18 -12
  155. data/lib/hecks/vendoring/git_environment.rb +35 -0
  156. data/lib/hecks/vendoring/git_source.rb +102 -0
  157. data/lib/hecks/vendoring.rb +102 -0
  158. data/lib/hecks/version.rb +1 -1
  159. data/lib/hecks/vocabulary.rb +5 -0
  160. data/lib/hecks.rb +8 -2
  161. metadata +72 -58
  162. data/lib/hecks/bench/cli.rb +0 -74
  163. data/lib/hecks/bench/environment.rb +0 -100
  164. data/lib/hecks/bench/postgres_probe.rb +0 -41
  165. data/lib/hecks/bench/report.rb +0 -116
  166. data/lib/hecks/bench/ruby_runner.rb +0 -151
  167. data/lib/hecks/bench/run.rb +0 -55
  168. data/lib/hecks/bench/rust_runner.rb +0 -165
  169. data/lib/hecks/bench/stats.rb +0 -75
  170. data/lib/hecks/bench/suite.rb +0 -172
  171. data/lib/hecks/bench/workload.rb +0 -137
  172. data/lib/hecks/bench.rb +0 -28
  173. data/lib/hecks/codemod.rb +0 -443
  174. data/lib/hecks/corpus.rb +0 -479
  175. data/lib/hecks/doc/reference.rb +0 -585
  176. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -684
  177. data/lib/hecks/fuzzing/combination_miner.rb +0 -178
  178. data/lib/hecks/fuzzing/concurrent_dispatch.rb +0 -469
  179. data/lib/hecks/fuzzing/coverage_campaign.rb +0 -161
  180. data/lib/hecks/fuzzing/differential.rb +0 -192
  181. data/lib/hecks/fuzzing/domain_generator.rb +0 -871
  182. data/lib/hecks/fuzzing/era_boundary.rb +0 -154
  183. data/lib/hecks/fuzzing/form_census.rb +0 -285
  184. data/lib/hecks/fuzzing/generated_domain_check.rb +0 -171
  185. data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -125
  186. data/lib/hecks/fuzzing/isolated_boot.rb +0 -604
  187. data/lib/hecks/fuzzing/nondeterministic.rb +0 -79
  188. data/lib/hecks/fuzzing/persistence_parity.rb +0 -253
  189. data/lib/hecks/fuzzing/properties/corrections.rb +0 -125
  190. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +0 -688
  191. data/lib/hecks/fuzzing/properties/guards.rb +0 -398
  192. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +0 -399
  193. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +0 -137
  194. data/lib/hecks/fuzzing/properties/outbox.rb +0 -180
  195. data/lib/hecks/fuzzing/properties/querying.rb +0 -260
  196. data/lib/hecks/fuzzing/properties.rb +0 -267
  197. data/lib/hecks/fuzzing/qa_settings.rb +0 -164
  198. data/lib/hecks/fuzzing/replay.rb +0 -930
  199. data/lib/hecks/fuzzing/rotation_priority.rb +0 -114
  200. data/lib/hecks/fuzzing/rust_gap_manifest.rb +0 -139
  201. data/lib/hecks/fuzzing/self_consistency.rb +0 -825
  202. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +0 -532
  203. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +0 -171
  204. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +0 -127
  205. data/lib/hecks/fuzzing/sequence_generator/picker.rb +0 -116
  206. data/lib/hecks/fuzzing/sequence_generator/query_binding.rb +0 -138
  207. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +0 -344
  208. data/lib/hecks/fuzzing/sequence_generator.rb +0 -311
  209. data/lib/hecks/fuzzing/shrinker.rb +0 -309
  210. data/lib/hecks/fuzzing/structural_skips.rb +0 -53
  211. data/lib/hecks/fuzzing/sweep_depth.rb +0 -61
  212. data/lib/hecks/fuzzing/target_capabilities.rb +0 -255
  213. data/lib/hecks/fuzzing/value_generator.rb +0 -340
  214. data/lib/hecks/fuzzing.rb +0 -23
  215. data/lib/hecks/grammar/evolve.rb +0 -524
  216. data/lib/hecks/query_ir.rb +0 -482
@@ -1,585 +0,0 @@
1
- require_relative "../bluebook/meta_validator"
2
- require_relative "../naming"
3
-
4
- module Hecks
5
- module Doc
6
- # The DSL reference, projected from the language's own Syntax chapter
7
- # — the same Keyword/Argument rows the conformance specs hold equal
8
- # to the live builders. Nothing here is described twice: the tables
9
- # come from the declaration, the prose is hand-written between
10
- # markers the generator preserves, and the golden spec refuses a
11
- # tree where the two have drifted.
12
- #
13
- # Regenerate with bin/reference. A new word arrives with a TODO
14
- # sentinel; the coverage gate refuses to let an admitted word ship
15
- # undocumented; prose for a word the language no longer declares is
16
- # a hard error naming its orphans — deleting someone's writing is a
17
- # human's decision.
18
- module Reference
19
- GENERATED_END = "<!-- generated:end -->".freeze
20
- TODO_SENTINEL = "<!-- TODO: document this word -->".freeze
21
-
22
- # A page's own hand-written opening, harvested under a key no word
23
- # can ever collide with (words are strings off the Syntax chapter;
24
- # this is a Symbol). It exists so a page can boot once — load a real
25
- # corpus chapter, wire its hexagon — and have every word's example
26
- # below run against that single boot, the way a guide's opening
27
- # `ruby boot` block already does. Without it each word would have to
28
- # stand up its own domain, and 105 invented chapters would collide
29
- # on the facade constants `Surface.install` never uninstalls.
30
- PREAMBLE = :preamble
31
-
32
- module_function
33
-
34
- # The marker opening one word's generated region.
35
- #
36
- # @param word [String, Symbol, #to_s] the word this section documents
37
- # @return [String] the HTML comment marking that word's generated region open
38
- def generated_begin(word) = "<!-- generated:begin word=#{word} -->"
39
-
40
- # Keyed by region rather than by word — the same marker convention,
41
- # used for the parts of a page that are not about one word: a
42
- # page's generated lede here, README's generated indexes below.
43
- #
44
- # @param id [String, Symbol, #to_s] the region's id, such as `"page"` or `"tools"`
45
- # @return [String] the HTML comment marking that region's generated region open
46
- def region_begin(id) = "<!-- generated:begin id=#{id} -->"
47
-
48
- # The language's own Syntax aggregate, read off the judged grammar chapter.
49
- #
50
- # @return [Bluebook::Aggregate, nil] the Syntax aggregate, or nil if the
51
- # grammar's Bluebook chapter declares none by that name
52
- def syntax
53
- meta = Bluebook::MetaValidator.grammar_registry.bluebook("Bluebook")
54
- meta.aggregates.find { |aggregate| aggregate.hecks_name == "Syntax" }
55
- end
56
-
57
- # Reads one closed-set value object's declared members off the Syntax
58
- # aggregate, as string-valued Hashes.
59
- #
60
- # @param name [String, Symbol, #to_s] the value object's `hecks_name`,
61
- # such as `"Keyword"`
62
- # @return [Array<Hash{Symbol => String}>] each declared member's fields,
63
- # values stringified
64
- def rows(name)
65
- syntax.value_objects.find { |vo| vo.hecks_name == name }
66
- .members.map { |row| row.to_h.transform_values(&:to_s) }
67
- end
68
-
69
- # S14, ADR 0026 — Keyword/Argument are genuine entities of Syntax
70
- # now, dispatched (not merely declared) so their own `status`
71
- # really is a lifecycle. `SyntaxBoot.call` discovers the static
72
- # aggregate-local seed rows (`KeywordSeed`/`ArgumentSeed`), dispatches each
73
- # one through the real admission/lifecycle door, and hands back the
74
- # same shape `rows`, above, already produces — nothing below this
75
- # needed to change.
76
- #
77
- # This module holds no separate `@keywords ||=` memo of its own —
78
- # delegates straight through to `SyntaxBoot.call`'s own cache
79
- # (keyed on the grammar registry's own chapter set — see its
80
- # comment) rather than shadowing it. A second, local cache on top
81
- # of that one would double-cache with no way to invalidate either
82
- # half: whichever call happened to land first would lock in
83
- # forever, even one caught mid-build missing every Paging-attached
84
- # word (limit/offset/cursor/nulls).
85
- #
86
- # @return [Array<Hash{Symbol => String}>] every declared Keyword row
87
- def keywords = Bluebook::MetaValidator::SyntaxBoot.call[:keywords]
88
-
89
- # Every declared Argument row.
90
- #
91
- # @return [Array<Hash{Symbol => String}>] every declared Argument row
92
- def arguments = Bluebook::MetaValidator::SyntaxBoot.call[:arguments]
93
-
94
- # Reads a row's declared status, defaulting when it declared none.
95
- #
96
- # @param row [Hash{Symbol => String}] a Keyword or Argument row
97
- # @return [String] the row's declared status, defaulting to `"admitted"`
98
- # when it declared none
99
- def status_of(row) = row[:status].to_s.empty? ? "admitted" : row[:status].to_s
100
-
101
- # Whether a row is still current enough to appear in the reference.
102
- #
103
- # @param row [Hash{Symbol => String}] a Keyword or Argument row
104
- # @return [Boolean] true if the row's status is `"admitted"` or `"deprecated"`
105
- def live?(row) = %w[admitted deprecated].include?(status_of(row))
106
-
107
- # Every distinct context a keyword is declared in.
108
- #
109
- # @return [Array<String>] every distinct context a keyword is declared in
110
- def contexts = keywords.map { |row| row[:context] }.uniq
111
-
112
- # Derives a context's reference page filename.
113
- #
114
- # @param context [String, Symbol, #to_s] a context name, such as `"File"`
115
- # @return [String] the reference page's filename for that context
116
- def page_name(context) = "#{Naming.snake(context)}.md"
117
-
118
- # Every reference page, rendered fresh — prose carried over from
119
- # the committed pages, new words seeded with the sentinel, orphaned
120
- # prose refused.
121
- #
122
- # @param directory [String] path to the directory holding the committed
123
- # reference pages, read for their hand-written prose
124
- # @return [Hash{String => String}] every page's filename (plus `"index.md"`)
125
- # mapped to its freshly rendered Markdown content
126
- # @raise [RuntimeError] if a committed page carries prose for a word the
127
- # language no longer declares in that context
128
- def pages(directory)
129
- contexts.each_with_object({}) do |context, pages|
130
- path = File.join(directory, page_name(context))
131
- prose = File.exist?(path) ? harvest(File.read(path)) : {}
132
- pages[page_name(context)] = render_page(context, prose, path)
133
- end.merge("index.md" => render_index)
134
- end
135
-
136
- # A word admitting two forms has two rows — syntax.bluebook's own
137
- # stated rule, and `identified_by` (a block, or a bare argument and
138
- # none) is the case that made it real again. One section per word all
139
- # the same: the prose is the word's rather than the form's, and the
140
- # argument rows join by (word, context) and so already cover every
141
- # form. Grouped rather than rendered per row, or a reader would meet
142
- # the same heading and the same paragraph twice.
143
- #
144
- # @param context [String] the context name, such as `"File"`
145
- # @param prose [Hash{String, Symbol => String}] hand-written prose harvested
146
- # from the committed page, keyed by word (or `PREAMBLE` for the page's lede)
147
- # @param path [String] the page's file path, used only in the orphan-refusal
148
- # message below
149
- # @return [String] the page's full rendered Markdown
150
- # @raise [RuntimeError] if `prose` carries a key for a word the language no
151
- # longer declares in `context`
152
- def render_page(context, prose, path)
153
- words = keywords.select { |row| row[:context] == context }.group_by { |row| row[:word] }
154
- orphans = prose.keys - words.keys - [PREAMBLE]
155
- unless orphans.empty?
156
- raise "#{path} carries prose for #{orphans.join(', ')}, which the language no longer " \
157
- "declares in #{context} — deleting writing is a human's decision, so decide"
158
- end
159
-
160
- sections = words.map { |word, forms| render_word(forms, prose[word]) }
161
- preamble = prose[PREAMBLE].to_s.strip
162
- <<~PAGE
163
- # #{context}
164
-
165
- #{region_begin('page')}
166
- #{context_lede(context)}
167
-
168
- *The tables on this page are generated from the language's own
169
- aggregate-local syntax tables (`lib/hecks/language/**/*.bluebook`)
170
- by `bin/reference` — do not edit inside the markers. The prose
171
- between them is hand-written and survives regeneration.*
172
- #{GENERATED_END}
173
- #{"\n#{preamble}\n" unless preamble.empty?}
174
- #{sections.join("\n")}
175
- PAGE
176
- end
177
-
178
- # The one-line description of where a context's words are typed.
179
- #
180
- # @param context [String] the context name, such as `"File"` or `"Command"`
181
- # @return [String] a sentence naming where words in this context are typed
182
- def context_lede(context)
183
- openers = keywords.select { |row| row[:opens] == context }
184
- return "Words available at the top of a file." if context == "File"
185
- return "Words available in the type position of an `attribute`." if context == "Type"
186
-
187
- inside = openers.map { |row| "`#{row[:word]} do ... end`" }.uniq.join(" / ")
188
- inside.empty? ? "Words available in the #{context} body." : "Words available inside #{inside}."
189
- end
190
-
191
- # One spelling per form, everything else off the first row — the
192
- # columns that differ between two forms of one word are `body` (which
193
- # is what the spelling shows) and nothing else.
194
- #
195
- # @param forms [Array<Hash{Symbol => String}>] one word's Keyword rows, one
196
- # per admitted form
197
- # @param prose [String, nil] the word's hand-written prose, or nil if none
198
- # was harvested
199
- # @return [String] the word's rendered section, generated table plus prose
200
- def render_word(forms, prose)
201
- row = forms.first
202
- table = argument_table(row)
203
- facts = []
204
- facts << "opens a `#{row[:opens]}` body" unless row[:opens].to_s.empty?
205
- facts << "fills `#{row[:fills]}`" unless row[:fills].to_s.empty?
206
- facts << "**status: #{status_of(row)}**" unless status_of(row) == "admitted"
207
- facts << "was `#{row[:was]}`" unless row[:was].to_s.empty?
208
- spellings = forms.map { |form| "`#{signature(form)}`" }.join(" / ")
209
-
210
- <<~WORD
211
- ## #{row[:word]}
212
-
213
- #{generated_begin(row[:word])}
214
- #{spellings}#{" — #{facts.join(', ')}" unless facts.empty?}
215
- #{table}#{GENERATED_END}
216
-
217
- #{prose_or_sentinel(prose)}
218
- WORD
219
- end
220
-
221
- # Falls back to the TODO sentinel when a word has no prose yet.
222
- #
223
- # @param prose [String, nil] a word's hand-written prose, or nil if none
224
- # was harvested
225
- # @return [String] `prose` stripped, or the TODO sentinel if it is blank
226
- def prose_or_sentinel(prose)
227
- text = prose.to_s.strip
228
- text.empty? ? TODO_SENTINEL : text
229
- end
230
-
231
- # Finds the Argument rows declared for one Keyword row's word.
232
- #
233
- # @param row [Hash{Symbol => String}] a Keyword row
234
- # @return [Array<Hash{Symbol => String}>] the Argument rows declared for
235
- # this row's word, in this row's context
236
- def word_arguments(row)
237
- arguments.select { |arg| arg[:keyword] == row[:word] && arg[:context] == row[:context] }
238
- end
239
-
240
- # Builds the call spelling shown for one word.
241
- #
242
- # @param row [Hash{Symbol => String}] a Keyword row
243
- # @return [String] the word's call spelling, positional arguments then
244
- # named ones, with a trailing `do ... end` unless its body is `"none"`
245
- def signature(row)
246
- positional = word_arguments(row).reject { |arg| arg[:at].to_s.empty? }
247
- .sort_by { |arg| arg[:at].to_i }
248
- .map { |arg| arg[:fills].to_s.empty? ? arg[:kind] : arg[:fills] }
249
- named = word_arguments(row).select { |arg| arg[:at].to_s.empty? }
250
- .map { |arg| "#{arg[:named]}:" }
251
- parts = positional + named
252
- base = parts.empty? ? row[:word] : "#{row[:word]} #{parts.join(', ')}"
253
- row[:body].to_s == "none" ? base : "#{base} do ... end"
254
- end
255
-
256
- # Renders one word's arguments as a Markdown table.
257
- #
258
- # @param row [Hash{Symbol => String}] a Keyword row
259
- # @return [String] a Markdown table of the row's arguments, or `""` if it
260
- # declares none
261
- def argument_table(row)
262
- args = word_arguments(row)
263
- return "" if args.empty?
264
-
265
- lines = ["", "| argument | kind | required | fills |", "|---|---|---|---|"]
266
- args.each do |arg|
267
- name = arg[:at].to_s.empty? ? "`#{arg[:named]}:`" : "positional #{arg[:at]}"
268
- lines << "| #{name} | #{arg[:kind]} | #{arg[:required]} | #{arg[:fills]} |"
269
- end
270
- "#{lines.join("\n")}\n"
271
- end
272
-
273
- # Renders the reference index page.
274
- #
275
- # @return [String] the reference index page's rendered Markdown, one
276
- # linked entry per context
277
- def render_index
278
- listed = contexts.map do |context|
279
- count = keywords.select { |row| row[:context] == context }.map { |row| row[:word] }.uniq.size
280
- "- [#{context}](#{page_name(context)}) — #{count} #{count == 1 ? 'word' : 'words'}"
281
- end
282
- <<~INDEX
283
- # The DSL reference
284
-
285
- One page per context — the place in a file where a word may be
286
- typed. Generated by `bin/reference` from the Syntax chapter;
287
- the prose between the generated markers is hand-written and
288
- survives regeneration.
289
-
290
- #{listed.join("\n")}
291
- INDEX
292
- end
293
-
294
- # Prose keyed by word: everything between a section's generated
295
- # region and the next `## ` heading (or end of file).
296
- #
297
- # Starts on `PREAMBLE` rather than nil so the text between the page's
298
- # own generated lede and its first word heading is carried over too
299
- # instead of being silently dropped. A page written before that
300
- # region existed has no generated marker ahead of its first `## `,
301
- # so nothing is collecting when that heading arrives and no empty
302
- # preamble is invented — the older shape reads back unchanged.
303
- # A single-pass line-scanning state machine (current/collecting/
304
- # buffer/in_fence) — each branch mutates shared local state that
305
- # carries into the next iteration, so splitting per branch would
306
- # mean passing all four back and forth by reference every line.
307
- #
308
- # @param text [String] a committed reference page's full Markdown source
309
- # @return [Hash{String, Symbol => String}] hand-written prose, keyed by
310
- # word (or `PREAMBLE` for the page's lede); empty and TODO-sentinel-only
311
- # entries are dropped
312
- # rubocop:disable-next Metrics/PerceivedComplexity
313
- def harvest(text)
314
- prose = {}
315
- current = PREAMBLE
316
- collecting = false
317
- buffer = []
318
-
319
- # A heading inside a fence is not a heading. `## something` is an
320
- # ordinary Ruby comment, and now that every word's section carries
321
- # runnable code, one written at the left margin would otherwise
322
- # end that section mid-example and orphan the rest of it under a
323
- # word the language never declared.
324
- in_fence = false
325
-
326
- text.each_line do |line|
327
- in_fence = !in_fence if line.start_with?("```")
328
-
329
- if !in_fence && (match = line.match(/\A## (\S+)\s*\z/))
330
- prose[current] = buffer.join.strip if current && collecting
331
- current = match[1]
332
- collecting = false
333
- buffer = []
334
- elsif line.include?(GENERATED_END)
335
- collecting = true
336
- elsif collecting
337
- buffer << line
338
- end
339
- end
340
- prose[current] = buffer.join.strip if current && collecting
341
- prose.reject { |_word, text_| text_.empty? || text_ == TODO_SENTINEL }
342
- end
343
-
344
- # Renders every reference page and writes each to `directory`.
345
- #
346
- # @param directory [String] path to the directory to write pages into,
347
- # created if it does not exist
348
- # @return [void]
349
- # @raise [RuntimeError] if a committed page carries prose for a word the
350
- # language no longer declares in that context
351
- def write!(directory)
352
- FileUtils.mkdir_p(directory)
353
- pages(directory).each do |name, content|
354
- File.write(File.join(directory, name), content)
355
- end
356
- end
357
-
358
- # README's own generated regions — the same marker convention as a
359
- # reference page, keyed by region id instead of a word, so the
360
- # index a reader lands on first can't drift from what actually
361
- # exists on disk either.
362
- #
363
- # @param root [String] the repository root
364
- # @return [Hash{String => String}] each region id mapped to its freshly
365
- # rendered content
366
- def readme_regions(root)
367
- {
368
- "guides" => guide_index(root),
369
- "reference" => reference_index(root),
370
- "tools" => tool_table(root),
371
- "corpus" => corpus_roster(root),
372
- "diagrams" => diagram_showcase(root)
373
- }
374
- end
375
-
376
- # Lists every committed guide, linked and titled by its own heading.
377
- #
378
- # @param root [String] the repository root
379
- # @return [String] a Markdown list linking every committed guide, titled
380
- # by its own `# ` heading
381
- def guide_index(root)
382
- paths = Dir.glob(File.join(root, "docs/implemented/guides/*.md"))
383
- .reject { |p| %w[AUTHORING.md].include?(File.basename(p)) }
384
- lines = paths.map do |path|
385
- heading = File.foreach(path).find { |line| line.start_with?("# ") }
386
- title = heading ? heading.sub(/\A#\s*/, "").strip : File.basename(path)
387
- "- [#{title}](docs/implemented/guides/#{File.basename(path)})"
388
- end
389
- lines.join("\n")
390
- end
391
-
392
- # Links the reference index, with its context count.
393
- #
394
- # @param _root [String] unused; kept for the shape `readme_regions` calls
395
- # every region renderer with
396
- # @return [String] one line linking the reference index, with its context count
397
- def reference_index(_root)
398
- count = contexts.size
399
- "[The DSL reference](docs/implemented/reference/index.md) — #{count} contexts, generated from " \
400
- "the aggregate-local tables under `lib/hecks/language/` and held to them by " \
401
- "`spec/reference_golden_spec.rb`."
402
- end
403
-
404
- # Lists every `bin/` script that opens with a comment, one row each.
405
- #
406
- # @param root [String] the repository root
407
- # @return [String] a Markdown table of every `bin/` script with an opening
408
- # comment, one row each
409
- def tool_table(root)
410
- scripts = Dir.glob(File.join(root, "bin/*")).select { |p| File.file?(p) }.sort
411
- rows = scripts.filter_map { |path| [path, tool_summary(path)] }.select { |_, desc| desc }
412
- lines = ["| tool | |", "|---|---|"]
413
- rows.each { |path, desc| lines << "| `bin/#{File.basename(path)}` | #{desc} |" }
414
- lines.join("\n")
415
- end
416
-
417
- # The opening comment paragraph, not just the first line — a table
418
- # cell that trails off mid-clause reads worse than one that runs a
419
- # little long and says "...". A code-bearing comment (`field.name`,
420
- # `pattern:`) makes naive sentence-splitting on "." or ":" cut in
421
- # the wrong place, so this truncates on length alone.
422
- #
423
- # @param path [String] a `bin/` script's path
424
- # @return [String, nil] its opening comment paragraph, truncated to 140
425
- # characters, or nil if the script opens with no comment
426
- def tool_summary(path)
427
- comment_lines = []
428
- started = false
429
- File.foreach(path).first(10).each do |line|
430
- if line.start_with?("#") && !line.start_with?("#!")
431
- next if !started && line.strip == "#"
432
-
433
- started = true
434
- comment_lines << line.sub(/\A#\s?/, "").rstrip
435
- elsif started
436
- break
437
- end
438
- end
439
- return nil if comment_lines.empty?
440
-
441
- text = comment_lines.join(" ").squeeze(" ")
442
- text.length > 140 ? "#{text[0, 137]}..." : text
443
- end
444
-
445
- # One real, committed file, read fresh — not re-derived from a boot
446
- # (this module never requires `hecks/projections/diagrams`, and
447
- # shouldn't just to draw one example). `docs/generated/diagrams/`
448
- # is already held to the declaration by `spec/diagrams_spec.rb`'s
449
- # own drift check; this just quotes its own output, so the two
450
- # can't independently drift from each other either — a stale
451
- # Order_lifecycle.mmd fails that spec long before this one runs.
452
- #
453
- # @param root [String] the repository root
454
- # @return [String] a Markdown section showcasing the generated diagram
455
- # tooling, quoting `examples/pizzas`'s own committed Order lifecycle diagram
456
- def diagram_showcase(root)
457
- lifecycle = File.read(File.join(root, "docs/generated/diagrams/pizzas/Order_lifecycle.mmd")).strip
458
- <<~MARKDOWN.strip
459
- `bin/project_diagrams` reads a booted domain's own declaration and draws it as Mermaid — nine kinds so far: `<Name>_lifecycle.mmd`, `relationships.mmd`, `dispatch.mmd`, `roles.mmd`, `ports.mmd`, `read_models.mmd`, `<Name>_surface.mmd` (what a command does, and what it writes), `<Name>_saga.mmd`, and `frameworks.mmd`. Nothing hand-drawn — the same reason a domain is data at all. Order's own lifecycle, straight off the bluebook above:
460
-
461
- ```mermaid
462
- #{lifecycle}
463
- ```
464
-
465
- The full set for every domain in this checkout — `examples/pizzas`, `examples/banking` — lives in [`docs/generated/diagrams/`](docs/generated/diagrams/), held to the declaration by `spec/diagrams_spec.rb` the same drift-refusing way this page is held to its own source.
466
- MARKDOWN
467
- end
468
-
469
- # Lists every example domain, with its own declared vision.
470
- #
471
- # @param root [String] the repository root
472
- # @return [String] a Markdown list of every example domain with a
473
- # `.bluebook` file, each with its own declared `vision` text
474
- def corpus_roster(root)
475
- dirs = Dir.glob(File.join(root, "examples/*/"))
476
- lines = dirs.filter_map do |dir|
477
- name = File.basename(dir.chomp("/"))
478
- bluebooks = Dir.glob(File.join(dir, "bluebook/*.bluebook"))
479
- bluebooks = Dir.glob(File.join(dir, "*.bluebook")) if bluebooks.empty?
480
- next if bluebooks.empty?
481
-
482
- vision = bluebooks.filter_map { |bluebook| File.read(bluebook)[/vision\s+"([^"]*)"/, 1] }.first
483
- "- **#{name}** — #{vision}"
484
- end
485
- lines.join("\n")
486
- end
487
-
488
- # Replaces every generated region inside `text` with its freshly rendered
489
- # content, leaving the hand-written parts of README untouched.
490
- #
491
- # @param root [String] the repository root
492
- # @param text [String] the README's current full text
493
- # @return [String] the README's text, with each generated region refreshed
494
- def render_readme(root, text)
495
- readme_regions(root).reduce(text) do |current, (id, content)|
496
- pattern = /#{Regexp.escape(region_begin(id))}.*?#{Regexp.escape(GENERATED_END)}/m
497
- current.sub(pattern) { "#{region_begin(id)}\n#{content}\n#{GENERATED_END}" }
498
- end
499
- end
500
-
501
- # Regenerates README's generated regions in place.
502
- #
503
- # @param root [String] the repository root
504
- # @return [void]
505
- def write_readme!(root)
506
- path = File.join(root, "README.md")
507
- File.write(path, render_readme(root, File.read(path)))
508
- end
509
-
510
- # An example a reader can see and the harness will actually run.
511
- # `ruby skip` is display-only by the doctest harness's own rule, and
512
- # a hidden `<!-- doctest:boot -->` block is setup rather than an
513
- # example — a word whose only "example" is invisible or inert is a
514
- # word still shipping on its prose alone, which is the thing this
515
- # gate exists to refuse.
516
- EXAMPLE_FENCE = /^```ruby(?: bluebook| boot)?[ \t]*$/
517
-
518
- # Whether a word's prose carries a runnable example.
519
- #
520
- # @param prose [String, nil] a word's hand-written prose, or nil
521
- # @return [Boolean] true if `prose` contains a runnable `ruby` or
522
- # `ruby bluebook`/`ruby boot` fenced example
523
- def exemplified?(prose) = prose.to_s.match?(EXAMPLE_FENCE)
524
-
525
- # Every live word, paired with its prose. Both coverage gates ask a
526
- # question about this same walk and differ only in what they ask of
527
- # the prose, so they share it rather than each re-deriving the page
528
- # set — the two are meant to move together, and one drifting past
529
- # the other is how a word ends up counted documented by one and
530
- # missing to the other.
531
- #
532
- # `harvest` already rejects empty prose and the TODO sentinel, so a
533
- # word with nothing written for it arrives here with a nil.
534
- #
535
- # @param directory [String] path to the directory holding the committed
536
- # reference pages
537
- # @return [Array(String, String, String), Array(String, String, nil)]
538
- # `[word, context, prose]` for every live (admitted or deprecated) word,
539
- # `prose` nil if none was harvested
540
- def live_words(directory)
541
- rows = contexts.flat_map do |context|
542
- path = File.join(directory, page_name(context))
543
- prose = File.exist?(path) ? harvest(File.read(path)) : {}
544
- keywords.select { |row| row[:context] == context && live?(row) }
545
- .map { |row| [row[:word], context, prose[row[:word]]] }
546
- end
547
- rows.uniq { |word, context, _| [word, context] }
548
- end
549
-
550
- # Disambiguates a word by the context it is declared in.
551
- #
552
- # @param word [String] a word
553
- # @param context [String] the context it is declared in
554
- # @return [String] the word, disambiguated by its context
555
- def name_of(word, context) = "#{word} (#{context})"
556
-
557
- # The coverage gate's question: every live word with no prose yet.
558
- #
559
- # @param directory [String] path to the directory holding the committed
560
- # reference pages
561
- # @return [Array<String>] each undocumented live word, named by `name_of`
562
- def undocumented(directory)
563
- live_words(directory).reject { |_word, _context, prose| prose }
564
- .map { |word, context, _| name_of(word, context) }
565
- end
566
-
567
- # The second coverage gate: prose is a declaration, and a
568
- # declaration nothing runs cannot disagree with anything. A word
569
- # documented only in sentences can go stale — or describe a word
570
- # the runtime never wired at all, which this repository has already
571
- # shipped twice (`read_model`'s where/order_by/limit/offset, and
572
- # `role`/`goal` on a command). An example that runs is the only
573
- # documentation that can go red.
574
- #
575
- # @param directory [String] path to the directory holding the committed
576
- # reference pages
577
- # @return [Array<String>] each live word with prose but no runnable
578
- # example, named by `name_of`
579
- def unexemplified(directory)
580
- live_words(directory).reject { |_word, _context, prose| exemplified?(prose) }
581
- .map { |word, context, _| name_of(word, context) }
582
- end
583
- end
584
- end
585
- end