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,482 +0,0 @@
1
- require_relative "bluebook"
2
- require_relative "codemod"
3
- require_relative "projections/model"
4
- require_relative "fuzzing/properties"
5
-
6
- module Hecks
7
- # The shared core behind `bin/query_ir` (a text CLI) and
8
- # `bin/hecks_query_ir_mcp` (an MCP server exposing the same two
9
- # queries as tools) — one implementation, two front ends, the same
10
- # reason `Hecks::Codemod` exists once rather than per-script.
11
- # Every method here returns structured data (Hashes/Arrays/Structs),
12
- # never formatted text — formatting is each front end's own job.
13
- module QueryIR
14
- Codemod = Hecks::Codemod
15
- Deviations = Hecks::Projections::Model::Deviations
16
-
17
- # The same mapping spec/model_shape_conformance_spec.rb's own
18
- # MODEL_CONSTRUCTS holds — kept here rather than shared from the
19
- # spec (a spec file is not a library other code should require),
20
- # matching Deviations' own doc comment: "the generator and the gate
21
- # read one source" — Deviations is that one source; this table is
22
- # small and genuinely construct-list bookkeeping, not a rule that
23
- # can drift silently the way a Deviations entry could.
24
- CONSTRUCTS = {
25
- "Bluebook" => Hecks::Bluebook::Chapter,
26
- "Aggregate" => Hecks::Bluebook::Aggregate,
27
- "Command" => Hecks::Bluebook::Command,
28
- "Entity" => Hecks::Bluebook::Entity,
29
- "ValueObject" => Hecks::Bluebook::ValueObject,
30
- "Policy" => Hecks::Bluebook::Policy,
31
- "Query" => Hecks::Bluebook::Query,
32
- "ReadModel" => Hecks::Bluebook::ReadModel,
33
- "ProcessManager" => Hecks::Bluebook::ProcessManager
34
- }.freeze
35
-
36
- Rule = Struct.new(:kind, :description, :canonical, :location, keyword_init: true)
37
-
38
- module_function
39
-
40
- # Reads the fields the language declares for one construct kind, from the grammar itself.
41
- #
42
- # @param name [String] a `CONSTRUCTS` key, such as `"Aggregate"`
43
- # @return [Array<Symbol>] the attribute names the self-hosted meta-domain
44
- # grammar declares for `name`
45
- def meta_declared(name)
46
- Hecks::Bluebook::MetaValidator.grammar_registry
47
- .bluebook("Bluebook").aggregate(name).attributes.map(&:name)
48
- end
49
-
50
- # The real structural diff between what a Ruby IR class emits
51
- # (Class.ir_spec.keys) and what the self-hosted meta-domain declares
52
- # for it — the same comparison spec/model_shape_conformance_spec.rb
53
- # makes, reusing its own Deviations data so this can never silently
54
- # drift from what that gate actually checks.
55
- #
56
- # @param name [String] a `CONSTRUCTS` key, such as `"Aggregate"`
57
- # @return [Hash{Symbol => Object}] `:name`, `:declared`, `:emitted`, plus
58
- # `:missing_from_ruby` and `:unaccounted_in_ruby` — each an `Array<Symbol>`
59
- # @raise [ArgumentError] if `name` is not a `CONSTRUCTS` key
60
- def construct_diff(name)
61
- klass = CONSTRUCTS.fetch(name) do
62
- raise ArgumentError, "no such construct #{name.inspect} — known: #{CONSTRUCTS.keys.join(', ')}"
63
- end
64
- declared = meta_declared(name)
65
- emitted = klass.ir_spec.keys
66
-
67
- accounted = declared.reject { |field| Deviations.parent_ref?(field) } -
68
- Deviations.judge_only(name) -
69
- Deviations.folded(name).values.flatten -
70
- Deviations.off_the_wire(name) -
71
- Deviations.dynamic_tail(name) -
72
- Deviations.unpacked(name).keys
73
-
74
- unaccounted = emitted -
75
- declared -
76
- Deviations.contained(name) -
77
- Deviations.folded(name).keys -
78
- Deviations.computed(name) -
79
- Deviations.unpacked(name).values.flatten
80
-
81
- { name: name, declared: declared, emitted: emitted,
82
- missing_from_ruby: accounted - emitted, unaccounted_in_ruby: unaccounted }
83
- end
84
-
85
- # Diffs one or every construct kind at once.
86
- #
87
- # @param names [Array<String>] `CONSTRUCTS` keys to diff, every construct when empty
88
- # @return [Array<Hash>] one `construct_diff` result per name
89
- def constructs(names = [])
90
- targets = names.empty? ? CONSTRUCTS.keys : names
91
- targets.map { |name| construct_diff(name) }
92
- end
93
-
94
- # Every given/ensures/invariant declaration reachable from a booted
95
- # registry, walked recursively — every owner's own `.preconditions`/
96
- # `.invariants` (block-declared rules), every value object's own
97
- # `.invariants`, and every command's own `.givens`/`.ensures`
98
- # (`.givens` too, not just `.ensures` — a command's own local
99
- # `given("x") { block }` not yet hoisted to its owner, round 4's own
100
- # starting shape, would otherwise be invisible).
101
- #
102
- # Not keyed by object identity (a real, hard-won correction — see
103
- # the comment on `duplicates`' own dedup below for why: a bare
104
- # `given("x")` reference and its owner's own block declaration are
105
- # the same Ruby object at DSL build time, but `MetaValidator.call`
106
- # (S14 — every bluebook is judged by dispatching its own IR into the
107
- # self-hosted grammar, then reconstructed via `Assembly.call` from
108
- # flat rows) rebuilds the whole graph fresh from there. By the time
109
- # any caller reads `chapter.aggregates`, every given/invariant is
110
- # already a distinct object, whether it was block-declared or
111
- # bare-referenced — object identity carries no signal past that
112
- # point, for any construct, not just this one).
113
- #
114
- # @param registry [Runtime::Registry] the booted registry to walk
115
- # @param chapter_name [String, nil] one chapter to walk, every booted chapter when nil
116
- # @return [Array<Rule>]
117
- def collect_rules(registry, chapter_name = nil)
118
- rules = []
119
- chapters = chapter_name ? [registry.bluebook(chapter_name)] : registry.bluebooks.values
120
-
121
- chapters.each do |chapter|
122
- chapter.aggregates.each do |aggregate|
123
- walk_construct_rules(aggregate, aggregate.hecks_name, rules)
124
- aggregate.value_objects.each do |vo|
125
- vo.invariants.each do |rule|
126
- rules << Rule.new(kind: "invariant", description: rule.description, canonical: rule.canonical,
127
- location: "#{aggregate.hecks_name}::#{vo.hecks_name} (declared)")
128
- end
129
- end
130
- end
131
- end
132
- rules
133
- end
134
-
135
- # The recursive walk `collect_rules` drives — pulled out of that method
136
- # (pure extraction, identical traversal and Rule shapes) as its own
137
- # named, self-recursive method rather than a lambda closing over the
138
- # same locals. `rules` is the one piece of state every call shares —
139
- # threaded as a parameter and mutated in place, the same accumulator
140
- # role the lambda's own closure played.
141
- def walk_construct_rules(construct, path, rules)
142
- (construct.respond_to?(:preconditions) ? construct.preconditions : []).each do |rule|
143
- rules << Rule.new(kind: "given", description: rule.description, canonical: rule.canonical,
144
- location: "#{path} (declared)")
145
- end
146
- (construct.respond_to?(:invariants) ? construct.invariants : []).each do |rule|
147
- rules << Rule.new(kind: "invariant", description: rule.description, canonical: rule.canonical,
148
- location: "#{path} (declared)")
149
- end
150
- construct.commands.each do |command|
151
- command.givens.each do |rule|
152
- rules << Rule.new(kind: "given", description: rule.description, canonical: rule.canonical,
153
- location: "#{path}.#{command.hecks_name}")
154
- end
155
- command.ensures.each do |rule|
156
- rules << Rule.new(kind: "ensures", description: rule.description, canonical: rule.canonical,
157
- location: "#{path}.#{command.hecks_name}")
158
- end
159
- end
160
- return unless construct.respond_to?(:entities)
161
-
162
- construct.entities.each do |piece|
163
- walk_construct_rules(piece, "#{path}.#{piece.hecks_name}", rules)
164
- end
165
- end
166
- private_class_method :walk_construct_rules
167
-
168
- # A rule's owner — the construct path a "(declared)" location names
169
- # directly, or (for a command-level `.givens`/`.ensures` entry) the
170
- # path with its trailing `.CommandName` segment stripped. Two rules
171
- # sharing an owner are the same declaration read twice (an owner's
172
- # own precondition, and a command under it referencing that
173
- # precondition by name) — not two independent ones.
174
- #
175
- # Public, not a `duplicates`-only internal — `bin/codemod_hoist_
176
- # local_givens` reads it directly to group `collect_rules`' own
177
- # output by owner itself, the same reading `duplicates`' own
178
- # `declaration_count` makes.
179
- #
180
- # @param location [String] a `Rule#location`
181
- # @return [String] the owning construct's path, with `" (declared)"` or a trailing
182
- # `.CommandName` segment stripped
183
- def owner_of(location)
184
- return location.sub(/ \(declared\)\z/, "") if location.end_with?(" (declared)")
185
-
186
- location.rpartition(".").first
187
- end
188
-
189
- # Grouped by (kind, description, canonical), not canonical text
190
- # alone — a generic one-liner like `!value.to_s.empty?` legitimately
191
- # recurs dozens of times for unrelated fields; the real signal is
192
- # the same rule (same description, same predicate), which is also
193
- # exactly what the given/invariant reference mechanism itself
194
- # resolves on.
195
- #
196
- # Deduped by owner, not object identity (`collect_rules`' own
197
- # comment has the full story — identity is gone by the time this
198
- # reads the registry). Within a group, every command-level rule
199
- # whose owner already has its own "(declared)" entry in the same
200
- # group is just that declaration read again through a reference —
201
- # `Account.Open`/`Account.Credit`/etc. all naming `Account`'s own
202
- # `given("customer is active")` count as Account's one declaration,
203
- # not nine. A command-level rule with no matching owner declaration
204
- # (a local, not-yet-hoisted `given("x") { block }`) counts as its
205
- # own standalone declaration — two different commands independently
206
- # writing the identical local predicate is two declarations, a real
207
- # hoisting opportunity. A group is reported only when it adds up to
208
- # more than one real declaration this way.
209
- #
210
- # `domains: []` means "the self-hosted meta-domain only" — pass real
211
- # domain directories explicitly to include them, or `nil` (the
212
- # default) for meta-domain plus every real example.
213
- #
214
- # @param domains [Array<String>, nil] domain root directories to scan; every real
215
- # example (`Codemod::EXAMPLE_ROOTS`) when nil, none beyond the meta-domain when `[]`
216
- # @param include_meta [Boolean] whether to also scan the self-hosted meta-domain
217
- # @return [Array<Hash>] one entry per duplicate group: `:kind`, `:description`,
218
- # `:canonical`, and `:locations` (`Array<String>`)
219
- def duplicates(domains: nil, include_meta: true)
220
- domains ||= Codemod::EXAMPLE_ROOTS
221
- all_rules = []
222
- all_rules.concat(collect_rules(Codemod.meta_registry)) if include_meta
223
-
224
- domains.each do |domain_dir|
225
- bluebook_files = Dir.glob(File.join(domain_dir, "bluebook", "*.bluebook"))
226
- next if bluebook_files.empty?
227
-
228
- registry = Codemod.load_bluebook(bluebook_files)
229
- all_rules.concat(collect_rules(registry))
230
- end
231
-
232
- all_rules.group_by { |r| [r.kind, r.description, r.canonical] }
233
- .map { |key, rules| [key, rules, declaration_count(rules)] }
234
- .select { |_, _, count| count > 1 }
235
- .map do |(kind, description, canonical), rules, _|
236
- { kind: kind, description: description, canonical: canonical,
237
- locations: rules.map(&:location) }
238
- end
239
- end
240
-
241
- # `given` only (not `invariant`/`ensures`) also covers cross-entity
242
- # coverage — one piece's own entity-level declaration, shared with
243
- # any other piece nested under the same root aggregate (real corpus
244
- # this closes: SafeDepositBox's own `Visit`/`KeyIssuance`, two
245
- # different pieces on one head). A rule owned by a nested entity
246
- # (its own owner path has more than one segment) is covered when
247
- # some "(declared)" entry exists anywhere under that same root
248
- # aggregate — not just under its own exact owner — matching the
249
- # DSL's own pool, threaded unchanged through an aggregate's whole
250
- # entity tree (`AggregateBuilder#entity`'s own comment).
251
- #
252
- # A known, accepted gap this does not (and structurally cannot)
253
- # close: chapter-wide given sharing (`AggregateBuilder#given`'s own
254
- # bare form, `docs/implemented/resolution-rules/chapter-given.md`) — `Account`,
255
- # `SafeDepositBox`, and `OnboardingCase` each still show as their
256
- # own "(declared)" owner here even after `SafeDepositBox`/
257
- # `OnboardingCase` were converted to bare chapter-wide references,
258
- # because a referenced given still write-throughs into its own
259
- # aggregate's `@named_givens` — the same reason `collect_rules`'
260
- # own top comment already gives for why object identity carries no
261
- # signal past a bluebook's own build: the exported IR cannot tell
262
- # "I declared this myself" apart from "I referenced someone else's
263
- # declaration," because by the time anything reads `chapter.
264
- # aggregates`, both look identical. Closing this would mean reading
265
- # source text (bare `given(desc)` vs. block `given(desc) { ... }`),
266
- # not the built IR this query is deliberately built on — a
267
- # different, source-level tool, not a fix to this one. Treat a
268
- # still-flagged group naming multiple aggregates as "verify by
269
- # hand whether this is already a chapter-wide reference before
270
- # assuming it's fresh duplication," not as an automatic signal
271
- # either way.
272
- #
273
- # The identical gap, one level down: chapter-wide entity-scoped
274
- # sharing (`EntityBuilder#given`'s own bare form,
275
- # `docs/implemented/resolution-rules/chapter-entity-given.md`) hits this same wall for
276
- # the same structural reason — `SafeDepositBox.Visit` still shows
277
- # as its own "(declared)" owner here even after becoming a bare
278
- # reference to `Account.LedgerEntry`'s declaration, because a piece
279
- # resolving a chapter-wide reference still write-throughs the
280
- # resolved `Given` into its own `@named_givens` (so its own
281
- # commands can read it back locally without a second hop). This is
282
- # not a new limitation this feature introduces — it is the exact
283
- # same IR-cannot-distinguish-declared-from-referenced fact, one
284
- # scope wider. `bin/query_ir duplicates` confirms this directly:
285
- # `Account.LedgerEntry (declared)` and `SafeDepositBox.Visit
286
- # (declared)` both appear under the same `given: "customer is
287
- # active"` group — verify by hand, same as the aggregate-level
288
- # case above, before assuming a group naming two pieces under
289
- # different aggregates is fresh duplication rather than an already-
290
- # resolved chapter-wide reference.
291
- #
292
- # @param rules [Array<Rule>] one duplicate-key group's own rules
293
- # @return [Integer] how many of `rules` are real, independent declarations
294
- def declaration_count(rules)
295
- declared = rules.select { |r| r.location.end_with?(" (declared)") }
296
- declared_owners = declared.to_set { |r| owner_of(r.location) }
297
- declared_given_roots = declared.select { |r| r.kind == "given" }
298
- .to_set { |r| owner_of(r.location).split(".").first }
299
-
300
- standalone = rules.reject do |r|
301
- owner = owner_of(r.location)
302
- next true if declared_owners.include?(owner)
303
- next true if r.kind == "given" && owner.include?(".") && declared_given_roots.include?(owner.split(".").first)
304
-
305
- false
306
- end
307
-
308
- declared_owners.size + standalone.size
309
- end
310
- private_class_method :declaration_count
311
-
312
- # Shared text formatting — both `bin/query_ir` (a text CLI) and
313
- # `bin/hecks_query_ir_mcp` (an MCP tool result, itself a text
314
- # block) want the identical human-readable rendering; only the
315
- # outer framing differs (plain stdout vs. a JSON-RPC content array).
316
- # @param diffs [Array<Hash>] `constructs`' own output
317
- # @return [String] the human-readable rendering
318
- def format_constructs(diffs)
319
- diffs.map do |diff|
320
- lines = ["== #{diff[:name]} =="]
321
- lines << " emits: #{diff[:emitted].join(', ')}"
322
- lines << " declares: #{diff[:declared].join(', ')}"
323
- if diff[:missing_from_ruby].empty? && diff[:unaccounted_in_ruby].empty?
324
- lines << " clean — every declared field is emitted (or a named deviation), nothing emitted is undeclared"
325
- else
326
- unless diff[:missing_from_ruby].empty?
327
- lines << " MISSING FROM RUBY (declared, not emitted, not a named deviation): " \
328
- "#{diff[:missing_from_ruby].join(', ')}"
329
- end
330
- unless diff[:unaccounted_in_ruby].empty?
331
- lines << " UNACCOUNTED IN RUBY (emitted, not declared, not a named deviation): " \
332
- "#{diff[:unaccounted_in_ruby].join(', ')}"
333
- end
334
- end
335
- lines.join("\n")
336
- end.join("\n\n")
337
- end
338
-
339
- # **One hand-typed construct-name per reconstruction method** — the only
340
- # two `MetaValidator::Reconstruction` methods not driven generically
341
- # through `Assembly::Contracts`' own table (its own header explains
342
- # why: `aggregate(row)`/`entity(row)` predate the table and were
343
- # never migrated). `impact_preview`'s own touchpoint 4 is checked
344
- # only for these two — every other construct is read generically, so
345
- # asking "does Command's own reconstruction method mention this
346
- # field" is a question with no method to check.
347
- RECONSTRUCTION_METHODS = { "Aggregate" => :aggregate, "Entity" => :entity }.freeze
348
-
349
- # The six touchpoints `.claude/skills/bluebook-construct-creator/
350
- # SKILL.md` walks in prose, checked structurally instead of by hand
351
- # — for a construct/field pair not yet fully propagated (typically
352
- # mid-round, deciding what's left), or as a sanity check before the
353
- # final gate sweep of a round already believed done. Every check
354
- # here is best-effort and advisory, not a gate: a `false` does not
355
- # always mean "not yet done" (a field can be legitimately exempt —
356
- # `Deviations`' own named categories, `GUARANTEED_BY_CONSTRUCTION`,
357
- # or `META_DOMAIN_KNOWN_GAPS`, the last of which lives in
358
- # spec/fuzzing/meta_domain_coverage_spec.rb, a spec file this
359
- # module deliberately never requires — see `CONSTRUCTS`' own
360
- # comment on the same principle). Read the touchpoint's own
361
- # existing gate (`model_shape_conformance_spec.rb`,
362
- # `assembly_spec.rb`, `meta_domain_coverage_spec.rb`) before trusting
363
- # a `false` here as a real gap.
364
- # @param name [String] a `CONSTRUCTS` key, such as `"Aggregate"`
365
- # @param field [String, Symbol] the declared field to check propagation for
366
- # @return [Hash{Symbol => Object}] `:name`, `:field`, and `:touchpoints` — an
367
- # `Array<Hash>` of `:touchpoint` (String) and `:present` (Boolean, or nil when the
368
- # touchpoint does not apply to `name`)
369
- # @raise [ArgumentError] if `name` is not a `CONSTRUCTS` key
370
- def impact_preview(name, field)
371
- CONSTRUCTS.fetch(name) { raise ArgumentError, "no such construct #{name.inspect} — known: #{CONSTRUCTS.keys.join(', ')}" }
372
- field = field.to_s
373
-
374
- {
375
- name: name,
376
- field: field,
377
- touchpoints: [
378
- { touchpoint: "meta-domain grammar declares it", present: meta_declared(name).map(&:to_s).include?(field) },
379
- { touchpoint: "docs/resolution-rules/ names it", present: resolution_rule_mentions?(field) },
380
- { touchpoint: "Assembly::Contracts consumes it", present: contract_consumes?(name, field) },
381
- { touchpoint: "Reconstruction's hand-typed method reads it", present: reconstruction_reads?(name, field) },
382
- { touchpoint: "fuzzer FEATURE_COVERAGE/GUARANTEED_BY_CONSTRUCTION claims it", present: fuzzer_claims?(name, field) },
383
- { touchpoint: "Rust mirror (rust/parser/src/parse/*.rs) mentions it", present: rust_mentions?(field) }
384
- ]
385
- }
386
- end
387
-
388
- def resolution_rule_mentions?(field)
389
- paths = Dir.glob(File.join(Codemod::ROOT, "docs/resolution-rules/*.md")) +
390
- Dir.glob(File.join(Codemod::ROOT, "docs/implemented/resolution-rules/*.md"))
391
- paths.any? { |path| File.read(path).include?(field) }
392
- end
393
- private_class_method :resolution_rule_mentions?
394
-
395
- def contract_consumes?(name, field)
396
- contract = Hecks::Bluebook::Assembly.contract(name)
397
- contract.fields.key?(field.to_sym) || contract.derived.key?(field.to_sym)
398
- rescue KeyError
399
- false
400
- end
401
- private_class_method :contract_consumes?
402
-
403
- # `Method#source_location` finds where the hand-typed method starts;
404
- # the method's own body ends at the next line indented no deeper
405
- # than its own `def` — the same boundary Ruby itself uses, read back
406
- # textually because there is no live AST here, only a file to grep a
407
- # slice of. `nil` (not `false`) for every other construct — this
408
- # touchpoint genuinely does not apply to them (`RECONSTRUCTION_
409
- # METHODS` only names the two hand-typed methods), and collapsing
410
- # "does not apply" into "not done" would misreport a construct that
411
- # was never supposed to have this touchpoint at all.
412
- def reconstruction_reads?(name, field)
413
- method_name = RECONSTRUCTION_METHODS[name]
414
- # rubocop:disable-next Style/ReturnNilInPredicateMethodDefinition -- nil vs
415
- # false is a deliberate distinction here: nil means "not applicable" (no
416
- # hand-typed method to check), false means "applicable, and it fails" —
417
- # see the spec's own "not-applicable (nil), not false" example.
418
- return nil unless method_name
419
-
420
- file, start_line = Hecks::Bluebook::MetaValidator::Reconstruction.instance_method(method_name).source_location
421
- lines = File.readlines(file)
422
- def_line = lines[start_line - 1]
423
- indent = def_line[/\A\s*/]
424
-
425
- body = lines[start_line..].take_while do |line|
426
- line.strip.empty? || line[/\A\s*/].size > indent.size || !line.lstrip.start_with?("def ")
427
- end
428
- body.join.include?("#{field}:")
429
- end
430
- private_class_method :reconstruction_reads?
431
-
432
- def fuzzer_claims?(name, field)
433
- key = "#{name}##{field}"
434
- Hecks::Fuzzing::Properties::FEATURE_COVERAGE.values.flatten.include?(key) ||
435
- Hecks::Fuzzing::Properties::GUARANTEED_BY_CONSTRUCTION.key?(key)
436
- end
437
- private_class_method :fuzzer_claims?
438
-
439
- def rust_mentions?(field)
440
- Dir.glob(File.join(Codemod::ROOT, "rust/parser/src/parse/*.rs")).any? { |path| File.read(path).include?(field) }
441
- end
442
- private_class_method :rust_mentions?
443
-
444
- # Renders one field's touchpoint checklist as text.
445
- #
446
- # @param preview [Hash] `impact_preview`'s own output
447
- # @return [String] the human-readable rendering
448
- def format_impact_preview(preview)
449
- lines = ["== #{preview[:name]}##{preview[:field]} =="]
450
- preview[:touchpoints].each do |t|
451
- mark = if t[:present].nil?
452
- "n/a"
453
- else
454
- (t[:present] ? "yes" : "NOT YET")
455
- end
456
- lines << " [#{mark.rjust(7)}] #{t[:touchpoint]}"
457
- end
458
- done = preview[:touchpoints].count { |t| t[:present] == true }
459
- total = preview[:touchpoints].count { |t| !t[:present].nil? }
460
- lines << ""
461
- lines << "#{done}/#{total} applicable touchpoint(s) show signs of this field — advisory, not a gate; " \
462
- "a NOT YET can be a legitimate exemption (Deviations, GUARANTEED_BY_CONSTRUCTION, or a spec-only " \
463
- "META_DOMAIN_KNOWN_GAPS entry this module deliberately never reads)."
464
- lines.join("\n")
465
- end
466
-
467
- # Renders the duplicate-rule groups as text.
468
- #
469
- # @param groups [Array<Hash>] `duplicates`' own output
470
- # @return [String] the human-readable rendering
471
- def format_duplicates(groups)
472
- return "no duplicate given/invariant/ensures rule found" if groups.empty?
473
-
474
- body = groups.map do |group|
475
- ["== #{group[:kind]}: #{group[:description].inspect} — #{group[:canonical]} ==",
476
- *group[:locations].map { |loc| " #{loc}" }].join("\n")
477
- end.join("\n\n")
478
-
479
- "#{body}\n\n#{groups.size} duplicate group(s), #{groups.sum { |g| g[:locations].size }} declarations total"
480
- end
481
- end
482
- end