hecks 0.3.0 → 1.0.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 (194) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/d1.rb +38 -14
  3. data/lib/hecks/adapters/driven/governance_authorization.rb +41 -2
  4. data/lib/hecks/adapters/driven/heki/journal.rb +7 -2
  5. data/lib/hecks/adapters/driven/heki/saga_store.rb +0 -0
  6. data/lib/hecks/adapters/driven/heki/snapshot.rb +31 -4
  7. data/lib/hecks/adapters/driven/heki.rb +40 -9
  8. data/lib/hecks/adapters/driven/lambda.rb +16 -2
  9. data/lib/hecks/adapters/driven/postgres/codec.rb +8 -0
  10. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +45 -6
  11. data/lib/hecks/adapters/driven/postgres.rb +76 -29
  12. data/lib/hecks/adapters/driven/postgres_era.adapter +11 -0
  13. data/lib/hecks/adapters/driven/sqlite/codec.rb +17 -0
  14. data/lib/hecks/adapters/driven/sqlite/projection.rb +76 -9
  15. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +17 -5
  16. data/lib/hecks/adapters/driven/sqlite.rb +30 -9
  17. data/lib/hecks/adapters/driven.rb +19 -1
  18. data/lib/hecks/behaviors/dsl.rb +29 -0
  19. data/lib/hecks/behaviors/expectations.rb +62 -2
  20. data/lib/hecks/bluebook/assembly/contracts.rb +36 -7
  21. data/lib/hecks/bluebook/assembly/marks.rb +4 -3
  22. data/lib/hecks/bluebook/assembly.rb +14 -1
  23. data/lib/hecks/bluebook/behaviour/lifecycle.rb +18 -1
  24. data/lib/hecks/bluebook/behaviour/process_manager.rb +14 -1
  25. data/lib/hecks/bluebook/chapter.rb +21 -11
  26. data/lib/hecks/bluebook/command.rb +1 -1
  27. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +117 -5
  28. data/lib/hecks/bluebook/dsl/attribute_collector.rb +21 -0
  29. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +71 -2
  30. data/lib/hecks/bluebook/dsl/command_builder.rb +91 -3
  31. data/lib/hecks/bluebook/dsl/entity_builder.rb +129 -4
  32. data/lib/hecks/bluebook/dsl/policy_builder.rb +18 -3
  33. data/lib/hecks/bluebook/dsl/port_builder.rb +12 -3
  34. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +109 -10
  35. data/lib/hecks/bluebook/dsl/rule_reference.rb +1 -0
  36. data/lib/hecks/bluebook/dsl/word_gate.rb +9 -2
  37. data/lib/hecks/bluebook/dsl/world_builder.rb +44 -4
  38. data/lib/hecks/bluebook/expression/canonical_form.rb +71 -3
  39. data/lib/hecks/bluebook/expression/evaluator.rb +50 -7
  40. data/lib/hecks/bluebook/expression/projection.json +48 -0
  41. data/lib/hecks/bluebook/expression/resolver.rb +161 -10
  42. data/lib/hecks/bluebook/hexagon.rb +1 -1
  43. data/lib/hecks/bluebook/meta_validator/judge.rb +78 -16
  44. data/lib/hecks/bluebook/meta_validator/port_judge.rb +4 -0
  45. data/lib/hecks/bluebook/meta_validator/readings.rb +14 -4
  46. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +42 -3
  47. data/lib/hecks/bluebook/meta_validator/shapes.rb +30 -10
  48. data/lib/hecks/bluebook/meta_validator.rb +103 -13
  49. data/lib/hecks/bluebook/model_check.rb +132 -5
  50. data/lib/hecks/bluebook/pattern_subset.rb +66 -2
  51. data/lib/hecks/bluebook/process_manager.rb +53 -11
  52. data/lib/hecks/bluebook/value_object.rb +9 -1
  53. data/lib/hecks/doc/reference.rb +22 -1
  54. data/lib/hecks/facade/cli_door.rb +6 -3
  55. data/lib/hecks/facade/json_door.rb +16 -4
  56. data/lib/hecks/forms/app.rb +47 -6
  57. data/lib/hecks/forms/command_form_renderer.rb +1 -1
  58. data/lib/hecks/forms/field_renderer.rb +11 -4
  59. data/lib/hecks/forms/html.rb +31 -0
  60. data/lib/hecks/forms/params.rb +30 -1
  61. data/lib/hecks/forms/port_argument.rb +46 -0
  62. data/lib/hecks/forms/record_renderer.rb +6 -2
  63. data/lib/hecks/forms/record_table.rb +6 -1
  64. data/lib/hecks/framework/bluebook/console_settings.bluebook +19 -19
  65. data/lib/hecks/framework/bluebook/governance.bluebook +26 -11
  66. data/lib/hecks/framework/bluebook/identity.bluebook +2 -2
  67. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +527 -0
  68. data/lib/hecks/fuzzing/isolated_boot.rb +212 -18
  69. data/lib/hecks/fuzzing/properties.rb +52 -6
  70. data/lib/hecks/fuzzing/replay.rb +51 -18
  71. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +28 -2
  72. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +71 -6
  73. data/lib/hecks/fuzzing/sequence_generator.rb +5 -4
  74. data/lib/hecks/fuzzing/value_generator.rb +25 -2
  75. data/lib/hecks/grammar/evolve.rb +33 -0
  76. data/lib/hecks/grammar/expression.bluebook +8 -5
  77. data/lib/hecks/grammar/expression_operators.json +497 -1
  78. data/lib/hecks/language/bluebook/aggregate.bluebook +12 -12
  79. data/lib/hecks/language/bluebook/bluebook.bluebook +3 -3
  80. data/lib/hecks/language/bluebook/command.bluebook +42 -8
  81. data/lib/hecks/language/bluebook/entity.bluebook +86 -10
  82. data/lib/hecks/language/bluebook/policy.bluebook +21 -4
  83. data/lib/hecks/language/bluebook/process_manager.bluebook +135 -18
  84. data/lib/hecks/language/bluebook/projection.bluebook +6 -6
  85. data/lib/hecks/language/bluebook/query.bluebook +4 -4
  86. data/lib/hecks/language/bluebook/shape.bluebook +6 -6
  87. data/lib/hecks/language/bluebook/syntax.bluebook +12 -11
  88. data/lib/hecks/language/bluebook/vocabulary.bluebook +22 -7
  89. data/lib/hecks/language/oidc.json +20 -0
  90. data/lib/hecks/language/port.bluebook +30 -2
  91. data/lib/hecks/naming.rb +54 -1
  92. data/lib/hecks/ports/access_control.port +7 -2
  93. data/lib/hecks/ports/access_control.rb +1 -1
  94. data/lib/hecks/ports/agent.port +6 -2
  95. data/lib/hecks/ports/agent.rb +1 -1
  96. data/lib/hecks/ports/authentication.port +4 -2
  97. data/lib/hecks/ports/authentication.rb +1 -1
  98. data/lib/hecks/ports/authorization.port +5 -2
  99. data/lib/hecks/ports/authorization.rb +14 -11
  100. data/lib/hecks/ports/clock.port +3 -2
  101. data/lib/hecks/ports/clock.rb +1 -1
  102. data/lib/hecks/ports/extraction.port +3 -2
  103. data/lib/hecks/ports/extraction.rb +1 -1
  104. data/lib/hecks/ports/identity_assignment.port +3 -2
  105. data/lib/hecks/ports/identity_assignment.rb +1 -1
  106. data/lib/hecks/ports/identity_generation.port +3 -2
  107. data/lib/hecks/ports/identity_generation.rb +1 -1
  108. data/lib/hecks/ports/identity_resolution.port +3 -2
  109. data/lib/hecks/ports/identity_resolution.rb +1 -1
  110. data/lib/hecks/ports/persistence/append_only.rb +40 -4
  111. data/lib/hecks/ports/persistence/execution.rb +6 -1
  112. data/lib/hecks/ports/persistence/plugin.rb +54 -0
  113. data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_check.rb +41 -8
  114. data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard.rb +24 -56
  115. data/lib/hecks/ports/persistence/{lineage.rb → plugins/era/lineage.rb} +31 -4
  116. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/era_store.rb +3 -3
  117. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/head_compiler.rb +59 -10
  118. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/mint_transaction.rb +2 -2
  119. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/provisioning.rb +29 -1
  120. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/tail_merge.rb +11 -4
  121. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/transform_installer.rb +20 -0
  122. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage.rb +1 -1
  123. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/coverage_check.rb +5 -5
  124. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/era_resolver.rb +5 -2
  125. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/merge_coordinator.rb +2 -2
  126. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/minter.rb +4 -4
  127. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager.rb +2 -2
  128. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era.rb +116 -29
  129. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/approval_digest.rb +1 -1
  130. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_one.rb +14 -5
  131. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_two.rb +31 -6
  132. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/unfed_report.rb +5 -1
  133. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/reattest.rb +3 -3
  134. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/differ.rb +1 -1
  135. data/lib/hecks/ports/persistence/plugins/era.rb +48 -0
  136. data/lib/hecks/ports/persistence.rb +1 -1
  137. data/lib/hecks/ports/projection.rb +20 -0
  138. data/lib/hecks/projections/diagrams.rb +230 -1
  139. data/lib/hecks/projections/shape.rb +15 -2
  140. data/lib/hecks/projector/cli_projector.rb +10 -1
  141. data/lib/hecks/projector/exporter.rb +19 -2
  142. data/lib/hecks/query_ir.rb +19 -0
  143. data/lib/hecks/query_specification/common/null_policy.rb +13 -1
  144. data/lib/hecks/query_specification/field_path.rb +20 -2
  145. data/lib/hecks/query_specification/hop_path.rb +7 -5
  146. data/lib/hecks/runtime/aggregate_lock.rb +45 -0
  147. data/lib/hecks/runtime/boot_gates.rb +41 -0
  148. data/lib/hecks/runtime/caller.rb +19 -3
  149. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +13 -2
  150. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +12 -0
  151. data/lib/hecks/runtime/command_interpreter.rb +97 -13
  152. data/lib/hecks/runtime/command_rules/admissibility.rb +64 -14
  153. data/lib/hecks/runtime/command_rules/arithmetic.rb +7 -1
  154. data/lib/hecks/runtime/command_rules/authorization.rb +2 -1
  155. data/lib/hecks/runtime/command_rules/references.rb +27 -19
  156. data/lib/hecks/runtime/dependency_planning.rb +14 -0
  157. data/lib/hecks/runtime/dispatcher.rb +19 -4
  158. data/lib/hecks/runtime/entity_interpreter.rb +85 -14
  159. data/lib/hecks/runtime/errors.rb +22 -0
  160. data/lib/hecks/runtime/identity.rb +30 -2
  161. data/lib/hecks/runtime/instance.rb +59 -4
  162. data/lib/hecks/runtime/interpreting.rb +21 -0
  163. data/lib/hecks/runtime/loader.rb +59 -18
  164. data/lib/hecks/runtime/query_interpreter.rb +36 -4
  165. data/lib/hecks/runtime/reaction_invocation.rb +9 -1
  166. data/lib/hecks/runtime/read_model_interpreter.rb +76 -1
  167. data/lib/hecks/runtime/refusal_wording.rb +2 -0
  168. data/lib/hecks/runtime/registry/saga_persistence.rb +75 -3
  169. data/lib/hecks/runtime/registry/verification.rb +88 -0
  170. data/lib/hecks/runtime/registry.rb +69 -8
  171. data/lib/hecks/runtime/saga_interpreter.rb +215 -13
  172. data/lib/hecks/runtime/saga_pending_dispatch.rb +45 -0
  173. data/lib/hecks/runtime/value/admission.rb +19 -1
  174. data/lib/hecks/runtime/value/coercion.rb +75 -10
  175. data/lib/hecks/runtime.rb +17 -5
  176. data/lib/hecks/storehouse.rb +632 -0
  177. data/lib/hecks/version.rb +1 -1
  178. data/lib/hecks/vocabulary.rb +6 -1
  179. data/lib/hecks.rb +7 -2
  180. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +90 -0
  181. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +128 -0
  182. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +160 -0
  183. metadata +48 -37
  184. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard/shape_diff.rb +0 -0
  185. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_tamper.rb +0 -0
  186. /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/field_cache.rb +0 -0
  187. /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/resumable_backfill.rb +0 -0
  188. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/storage_shape.rb +0 -0
  189. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit.rb +0 -0
  190. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/rule_compiler.rb +0 -0
  191. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/renderer.rb +0 -0
  192. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/writer.rb +0 -0
  193. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold.rb +0 -0
  194. /data/lib/hecks/{translation.rb → ports/persistence/plugins/era/translation.rb} +0 -0
@@ -1,5 +1,5 @@
1
- require_relative "../../ports/persistence/append_only"
2
- require_relative "../../ports/persistence/lineage"
1
+ require_relative "../../../../../../ports/persistence/append_only"
2
+ require_relative "../../lineage"
3
3
 
4
4
  module Hecks
5
5
  module Translation
@@ -53,15 +53,14 @@ module Hecks
53
53
  return if rekeyed
54
54
 
55
55
  rules = Ports::Persistence::Lineage.from_declared(declared, aggregate.name)
56
- compute_tops = declared.computes.flat_map { |compute| [compute.from, compute.to] }
57
- .map { |path| path.to_s.split(".").first }
56
+ compute_paths = declared.computes.flat_map { |compute| [compute.from, compute.to] }.map(&:to_s)
58
57
 
59
58
  before.each do |id, state|
60
59
  next unless after.key?(id)
61
60
 
62
61
  entry = Ports::Persistence::Entry.new(operation: "save", id: id, state: state.transform_keys(&:to_sym))
63
- expected = normalize(rules.translate(entry).state).reject { |key, _| compute_tops.include?(key) }
64
- actual = normalize(after[id]).reject { |key, _| compute_tops.include?(key) }
62
+ expected = strip_compute_paths(normalize(rules.translate(entry).state), compute_paths)
63
+ actual = strip_compute_paths(normalize(after[id]), compute_paths)
65
64
  next if expected == actual
66
65
 
67
66
  diverged = (expected.keys | actual.keys).select { |key| expected[key] != actual[key] }
@@ -71,6 +70,32 @@ module Hecks
71
70
  end
72
71
 
73
72
  def normalize(state) = JSON.parse(JSON.generate(state))
73
+
74
+ # Exempts exactly the paths a compute owns, not the whole
75
+ # top-level attribute it happens to live under. A bare path
76
+ # ("price_cents") is itself the compute's entire value — dropping
77
+ # the whole top-level key is correct, there's nothing else there
78
+ # to check. A DOTTED path ("price.cents") only owns that one
79
+ # member of the value object it reaches into; every sibling
80
+ # member (e.g. "price.currency") is untouched by the compute and
81
+ # must stay subject to the equivalence check below. Blanket-
82
+ # dropping the whole top-level key for a dotted compute used to
83
+ # exempt the entire attribute — silent data loss elsewhere in the
84
+ # same value object (a migration that nulls or drops a sibling
85
+ # field) produced zero violations, defeating the one gate whose
86
+ # entire purpose is to catch exactly that.
87
+ def strip_compute_paths(state, paths)
88
+ paths.each do |path|
89
+ segments = path.split(".")
90
+ if segments.length == 1
91
+ state.delete(segments.first)
92
+ else
93
+ parent = segments[0..-2].reduce(state) { |node, segment| node.is_a?(Hash) ? node[segment] : nil }
94
+ parent.delete(segments.last) if parent.is_a?(Hash)
95
+ end
96
+ end
97
+ state
98
+ end
74
99
  end
75
100
  end
76
101
  end
@@ -33,7 +33,11 @@ module Hecks
33
33
  path.to_s.split(".").reduce(state) do |node, segment|
34
34
  break nil unless node.is_a?(Hash)
35
35
 
36
- node[segment] || node[segment.to_sym]
36
+ # `key?` decides which spelling answers, never `||` — a
37
+ # `false` genuinely held at this path must not fall through
38
+ # to the other spelling (usually absent) and read as `nil`,
39
+ # which would wrongly mark a `false`-valued attribute "unfed".
40
+ node.key?(segment) ? node[segment] : node[segment.to_sym]
37
41
  end
38
42
  end
39
43
  end
@@ -1,7 +1,7 @@
1
1
  require "tempfile"
2
- require_relative "../runtime/era_guard"
3
- require_relative "../runtime/registry"
4
- require_relative "../runtime/storage_shape"
2
+ require_relative "../era_guard"
3
+ require_relative "../../../../../runtime/registry"
4
+ require_relative "../storage_shape"
5
5
 
6
6
  module Hecks
7
7
  module Translation
@@ -1,4 +1,4 @@
1
- require_relative "../../runtime/storage_shape"
1
+ require_relative "../../storage_shape"
2
2
 
3
3
  module Hecks
4
4
  module Translation
@@ -0,0 +1,48 @@
1
+ require_relative "../plugin"
2
+ require_relative "era/era_check"
3
+ require_relative "era/era_guard"
4
+ require_relative "era/era_tamper"
5
+ require_relative "era/storage_shape"
6
+ require_relative "era/lineage"
7
+ require_relative "era/postgres_era"
8
+ require_relative "era/translation"
9
+
10
+ module Hecks
11
+ module Ports
12
+ module Persistence
13
+ module Plugins
14
+ # ADR 0033 — requiring THIS FILE is installing the plugin. Nothing in
15
+ # Hecks core requires it; an app that binds `PostgresEra`, or wants
16
+ # schema-translation support at all, requires it explicitly — the
17
+ # same shape every adapter-specific spec fixture already uses to
18
+ # load one particular `.adapter` file rather than all of them.
19
+ module Era
20
+ module_function
21
+
22
+ # `Runtime::Loader.run_boot_gates!` asks every loaded persistence
23
+ # plugin to contribute here, generically — it never mentions
24
+ # `EraCheck` or "era" by name. Two gates, both `:pre_verify`:
25
+ #
26
+ # `:era_compute_rules` — unconditional, whenever this plugin is
27
+ # loaded at all. A `compute`/`rekey` rule requires Postgres
28
+ # whatever adapter is actually bound (ADR 0031's own reasoning,
29
+ # unchanged) — this is the rich, adapter-aware version of that
30
+ # check; `Runtime::Loader`'s own structural backstop (plain
31
+ # `Bluebook::Translation` data, no plugin-specific class) only
32
+ # ever fires when NO persistence plugin is loaded at all.
33
+ #
34
+ # `:era_check` — conditional, exactly ADR 0031's own gate,
35
+ # unchanged: registered only when this registry has an aggregate
36
+ # actually bound to a lineage-capable adapter.
37
+ def contribute_boot_gates(registry, gates)
38
+ gates.register(:era_compute_rules, ->(reg, _dir) { Runtime::EraCheck.check_compute_rules_for_registry!(reg) }, phase: :pre_verify)
39
+ gates.register(:era_check, Runtime::EraCheck.method(:check_lineage!), phase: :pre_verify) if
40
+ Runtime::EraCheck.lineage_capable_registry?(registry)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+
48
+ Hecks::Ports::Persistence.register_plugin(:era, Hecks::Ports::Persistence::Plugins::Era)
@@ -9,7 +9,7 @@ module Hecks
9
9
  end
10
10
 
11
11
  require_relative "persistence/binding_policy"
12
- require_relative "persistence/lineage"
12
+ require_relative "persistence/plugin"
13
13
  require_relative "persistence/repository_factory"
14
14
  require_relative "persistence/append_only"
15
15
  require_relative "persistence/execution"
@@ -29,10 +29,30 @@ module Hecks
29
29
  class Worker
30
30
  attr_reader :projection
31
31
 
32
+ # The only two policies anything in this codebase ever passes
33
+ # (`bin/project`, every spec) — there is no third, legitimate
34
+ # "lenient append" policy on record anywhere. Before this, any
35
+ # value OTHER than the exact symbol `:strict` silently fell
36
+ # through the `consistent?` check below and appended onto
37
+ # divergent history without a word — not just a real typo like
38
+ # `:strikt`, but a caller-supplied String `"strict"` too (this
39
+ # duck-typed fine via `policy.to_sym`, but that was luck, not a
40
+ # contract: nothing here declared what a valid policy even was).
41
+ # Refusing loudly at construction, once, for anything outside
42
+ # this list turns a silent no-op into an immediate, named error
43
+ # — the "refuse rather than silently skip" reading of L1, since
44
+ # `:strict` really is meant to be the only enforcing contract.
45
+ VALID_POLICIES = %i[refresh strict].freeze
46
+
32
47
  def initialize(authoritative, projection, policy: :refresh)
33
48
  @authoritative = authoritative
34
49
  @projection = projection
35
50
  @policy = policy.to_sym
51
+ unless VALID_POLICIES.include?(@policy)
52
+ raise ArgumentError,
53
+ "unknown projection catch_up! policy #{@policy.inspect} — expected one of " \
54
+ "#{VALID_POLICIES.map(&:inspect).join(' or ')}"
55
+ end
36
56
  end
37
57
 
38
58
  # Invoke from a separate process or scheduler. The command-side write
@@ -48,7 +48,19 @@ module Hecks
48
48
  # declares at least one command
49
49
  # or query — everything you can
50
50
  # DO to it and ASK about it,
51
+ # AND what each command WRITES,
51
52
  # in one place
53
+ # <Name>_saga.mmd stateDiagram-v2 one per process_manager —
54
+ # its own states, and what
55
+ # each transition dispatches
56
+ # elsewhere in the domain
57
+ # frameworks.mmd flowchart every OTHER domain this one
58
+ # depends on — a shared
59
+ # framework it `uses_framework`,
60
+ # or a domain a policy reaches
61
+ # `across` — the one diagram
62
+ # here that looks OUTWARD past
63
+ # this domain's own boundary
52
64
  #
53
65
  # CONSTRUCT NAMES (aggregate/entity/command/event) ARE USED BARE,
54
66
  # UNSANITIZED, as Mermaid node/entity ids — safe because this
@@ -100,6 +112,14 @@ module Hecks
100
112
  files["#{holder.hecks_name}_surface.mmd"] = surface_diagram(bluebook, holder)
101
113
  end
102
114
 
115
+ bluebook.process_managers.each do |saga|
116
+ files["#{saga.hecks_name}_saga.mmd"] = saga_diagram(bluebook, saga)
117
+ end
118
+
119
+ if (diagram = frameworks_diagram(bluebook, options[:hecksagon]))
120
+ files["frameworks.mmd"] = diagram
121
+ end
122
+
103
123
  files
104
124
  end
105
125
 
@@ -397,17 +417,226 @@ module Hecks
397
417
  # anything. Command edges are solid ("does"); query edges are
398
418
  # dotted ("asks") — the same solid/dotted split `ports.mmd`
399
419
  # already uses for "routes to:" versus "exposes".
420
+ #
421
+ # A WRITE TARGET IS A PLAIN RECTANGLE — a sixth shape, the first
422
+ # here with no special bracket at all: an attribute is the
423
+ # smallest, most passive thing this vocabulary names, a single
424
+ # field living INSIDE the cylinder rather than a bounded thing of
425
+ # its own. `command.mutations` (`sets`/`increment`/`decrement`/
426
+ # `append`) was invisible everywhere before this — not just in a
427
+ # diagram, in ANY projection, including the prose ones — despite
428
+ # being the single densest fact in the whole IR (53 real
429
+ # mutations across pizzas + banking). `dispatch.mmd` draws what a
430
+ # command EMITS; this draws what it WRITES, the other half of
431
+ # "what actually happens" a command never showed before.
432
+ #
433
+ # THE SAME ATTRIBUTE NODE MERGES ACROSS COMMANDS — real in
434
+ # banking: `Account.Credit` and `Account.Debit` both point at the
435
+ # same `balance` node, the same "one node, several incoming
436
+ # edges" merge `read_models.mmd` already does for an aggregate
437
+ # fed by several read_models.
438
+ #
439
+ # THE LABEL NAMES THE REAL SOURCE, NOT JUST THE VERB — an
440
+ # increment/decrement/set almost always takes its value from an
441
+ # argument, but not always the SAME-NAMED one: real in banking,
442
+ # `Account.Credit`'s own `balance` is incremented by its
443
+ # `amount` argument, and `LedgerEntry.Amend`'s own `amount` is
444
+ # incremented by its `adjustment` argument. A literal source
445
+ # (pizzas' own `Order.Purchase` sets `status` to the literal
446
+ # `"sold"`, not an argument at all) is named as verbatim as
447
+ # every other fact in this file. `append`'s own fields carry no
448
+ # single source at all — its own field NAMES are the fact worth
449
+ # stating (real: `Order.AddTopping` appends `name, amount`).
400
450
  def surface_diagram(bluebook, holder)
401
451
  lines = holder.commands.map { |command| " #{holder.hecks_name}[(#{holder.hecks_name})] -->|does| #{command_node(holder.hecks_name, command.hecks_name)}" }
452
+ lines += holder.commands.flat_map { |command| command.mutations.map { |mutation| mutation_edge(holder, command, mutation) } }
402
453
  lines += holder.queries.map { |query| " #{holder.hecks_name}[(#{holder.hecks_name})] -.->|asks| #{query_node(holder.hecks_name, query.hecks_name)}" }
403
454
 
404
- subject = "#{holder.hecks_name}'s own declared commands and queries"
455
+ subject = "#{holder.hecks_name}'s own declared commands (and what each writes) and queries"
405
456
  "#{header(bluebook.name, subject)}flowchart LR\n#{lines.uniq.join("\n")}\n"
406
457
  end
407
458
 
408
459
  def query_node(aggregate_name, query_name)
409
460
  %(qry_#{aggregate_name}_#{query_name}{"#{aggregate_name}.#{query_name}"})
410
461
  end
462
+
463
+ def mutation_edge(holder, command, mutation)
464
+ shape = mutation.to_h
465
+ label = mutation_label(shape)
466
+ target = attribute_node(holder.hecks_name, shape[:target])
467
+ %( #{command_node(holder.hecks_name, command.hecks_name)} -->|"#{label}"| #{target})
468
+ end
469
+
470
+ def mutation_label(shape)
471
+ verb = "#{shape[:op]}s"
472
+ # `fields:` (not `source:`) IS the multi-binding shape
473
+ # (`Mutation#to_h`'s own `[:append, :delegate, :corrects]`
474
+ # branch) — checked by the KEY'S PRESENCE, not by re-listing
475
+ # which ops use it a second time here, the same lesson
476
+ # `Change.op`'s own `admits: Vocabulary::MutationOp` already
477
+ # drew (command.bluebook's own comment): a second list of "the
478
+ # ops that mean multi-binding" is exactly the kind of copy that
479
+ # drifts — `:delegate` already carried this shape with nothing
480
+ # here reading it correctly, caught only once `:corrects` gave
481
+ # banking's own real diagrams a fields:-shaped mutation to
482
+ # actually render.
483
+ detail = shape[:fields] ? shape[:fields].keys.join(", ") : mutation_source_detail(shape[:source])
484
+ "#{verb}: #{detail}"
485
+ end
486
+
487
+ # A LITERAL VALUE CAN CONTAIN A DOUBLE QUOTE OF ITS OWN — real in
488
+ # banking: `Customer.Reinstate` sets `standing` to a rendered
489
+ # value-object literal, `{:value=>"good"}`, whose own embedded `"`
490
+ # broke this label's outer `|"..."|` quoting outright (caught by
491
+ # running the real generated output through mermaid.parse(), not
492
+ # by eye — the same way `read_models.mmd`'s own unquoted `[]` bug
493
+ # was caught). Swapped for a single quote here rather than
494
+ # escaped, the same "state it, don't invent it, just make it
495
+ # legal Mermaid" trade `read_models.mmd`'s own quoting fix made.
496
+ def mutation_source_detail(source)
497
+ case source[:kind]
498
+ when "literal" then "'#{source[:value].to_s.tr('"', "'")}'"
499
+ when "argument" then source[:name]
500
+ else source[:kind] # a source kind this file has no real corpus example of yet — named, not hidden
501
+ end
502
+ end
503
+
504
+ def attribute_node(holder_name, attribute_name)
505
+ %(attr_#{holder_name}_#{attribute_name}[#{attribute_name}])
506
+ end
507
+
508
+ # ── sagas -> stateDiagram-v2 ─────────────────────────────────────
509
+
510
+ # A SAGA HAS A LIFECYCLE TOO — the same `stateDiagram-v2` shape
511
+ # `lifecycle_diagram` already draws, one file per process_manager
512
+ # the same way lifecycle is one file per lifecycle-bearing holder.
513
+ # What's different is the label: a lifecycle's own edge is labeled
514
+ # by the COMMAND that causes it (an aggregate transitions because
515
+ # something was DONE to it); a saga's edge is labeled by the EVENT
516
+ # that causes it (a saga advances because something HAPPENED,
517
+ # possibly nowhere near the saga itself) — the same command/event
518
+ # split `dispatch.mmd`'s own stadium/hexagon vocabulary already
519
+ # draws, here spent on which noun labels a stateDiagram-v2 edge
520
+ # instead.
521
+ #
522
+ # THE LABEL ALSO NAMES WHAT THE TRANSITION DISPATCHES — a fact no
523
+ # existing diagram states for a saga at all: a lifecycle's own
524
+ # edge only ever names the one command that caused it; a saga's
525
+ # edge can fire several commands at once (real in banking:
526
+ # Settlement's own AccountDebited handler dispatches both
527
+ # Transfer.Debited and Account.Credit). Confirmed real in the
528
+ # corpus: no saga dispatch ever declares a `to:`/`target_domain`
529
+ # of its own (unlike a policy's `across`) — every command a saga
530
+ # fires lands inside its own bluebook chapter, so this never needs
531
+ # `dispatch.mmd`'s own "triggers in X" cross-domain label.
532
+ #
533
+ # THE COMPENSATING LEG READS LIKE ANY OTHER — its own trigger is
534
+ # the literal string "refused" (`ProcessManager::REFUSED`, this
535
+ # language's own Trigger vocabulary), not invented text: a
536
+ # dispatch declined is exactly as real a cause of a state
537
+ # transition as an event announced, and the diagram states it
538
+ # exactly as verbatim as every other edge here does.
539
+ def saga_diagram(bluebook, saga)
540
+ edges = saga.handlers.map { |handler| saga_edge(handler, saga) }
541
+
542
+ subject = "#{saga.hecks_name}'s own declared states and what each transition dispatches " \
543
+ "(starts on #{saga.starts_on}, ends on #{saga.ends_on})"
544
+ <<~MERMAID
545
+ #{header(bluebook.name, subject)}stateDiagram-v2
546
+ [*] --> #{saga.states.first}
547
+ #{edges.join("\n")}
548
+ MERMAID
549
+ end
550
+
551
+ # THE REFUSED EDGE'S OWN DISPATCH LIST IS PARTLY DERIVED NOW —
552
+ # per-dispatch saga compensation (`compensates`) moved a saga's own
553
+ # compensating dispatches OFF the hand-written `on :refused` leg
554
+ # and onto whichever forward dispatch each one undoes, so
555
+ # `handler.dispatches` alone would render an EMPTY compensating
556
+ # edge for any saga using it — accurate to the DECLARATION, wrong
557
+ # about what the runtime actually does at refusal (it derives and
558
+ # fires every declared `compensates`, newest first). `saga` is
559
+ # passed through for exactly this — only the REFUSED handler needs
560
+ # it, every other edge's own `handler.dispatches` already says
561
+ # everything real about it.
562
+ def saga_edge(handler, saga)
563
+ label = handler.event_type
564
+ # DERIVED FIRST, then the hand-written body — the same order
565
+ # `SagaInterpreter#unwind` actually runs them in (every
566
+ # completed leg's own `compensates` before this leg's own
567
+ # hand-written dispatches), not declaration order on the page.
568
+ dispatched = handler.event_type == Bluebook::ProcessManager::REFUSED ? derived_compensations(saga) : []
569
+ dispatched += handler.dispatches.map(&:command_name)
570
+ label += " / dispatches #{dispatched.join(', ')}" unless dispatched.empty?
571
+
572
+ " #{handler.from_state} --> #{handler.to_state}: #{label}"
573
+ end
574
+
575
+ # Every `compensates` any forward dispatch in this saga declares,
576
+ # declaration order — the same commands `SagaInterpreter#unwind`
577
+ # derives and fires (newest-first, at actual refusal time; this
578
+ # diagram states them in declaration order, since it draws the
579
+ # saga's own shape, not one instance's own runtime history).
580
+ def derived_compensations(saga)
581
+ saga.handlers.flat_map { |handler| handler.dispatches.filter_map { |dispatch| dispatch.compensates&.command_name } }
582
+ end
583
+
584
+ # ── frameworks -> flowchart ─────────────────────────────────────
585
+
586
+ # EVERY OTHER DIAGRAM IN THIS FILE STAYS INSIDE ONE DOMAIN'S OWN
587
+ # BOUNDARY — this is the one that steps outside it. A real domain
588
+ # depends on another domain's own aggregates in exactly two ways:
589
+ # `uses_framework "X"` in its `.hecksagon` (`Hecksagon#framework_
590
+ # members`), which loads X's whole bluebook into THIS registry,
591
+ # unconditionally, the moment this domain boots; or a policy's own
592
+ # `across "X"` (`Policy#target_domain`), which only reaches X when
593
+ # the policy's declared event actually fires. Same underlying
594
+ # fact `dispatch.mmd`'s own `trigger_edge` already draws from the
595
+ # command's side ("triggers in X") — this draws it again from the
596
+ # DOMAIN's side, next to the structural `uses_framework` fact
597
+ # `dispatch.mmd` never sees at all (that lives in the `.hecksagon`,
598
+ # which no other diagram here is handed).
599
+ #
600
+ # NEITHER THIS DOMAIN NOR EACH DEPENDENCY GETS THE holders() TREATMENT
601
+ # — a whole domain is drawn as ONE cylinder, the same "a bounded,
602
+ # addressable thing" shape every other diagram here already spends
603
+ # on a single aggregate, just scaled up one level: a domain is a
604
+ # bigger box the same kind of box lives inside.
605
+ #
606
+ # DOTTED FOR `attaches`, SOLID FOR `reaches across` — the reverse
607
+ # of which fact is "always true" between the two: attaching a
608
+ # framework is a standing declaration, true every time this domain
609
+ # boots, so it gets the same dotted "this always belongs" treatment
610
+ # `ports.mmd` gives an aggregate's own `-.->|exposes|` edge.
611
+ # Reaching across only happens when a real policy actually fires —
612
+ # the same solid edge `dispatch.mmd`'s own `trigger_edge` already
613
+ # draws for the identical fact, kept solid here so the same
614
+ # relationship reads the same way in both diagrams.
615
+ #
616
+ # `options[:hecksagon]` IS THE ONE DIAGRAM IN THIS FILE THAT NEEDS
617
+ # MORE THAN `bluebook` — `framework_members` lives on the
618
+ # `Hecksagon`, a sibling IR object `bin/project_diagrams` already
619
+ # has in hand (`registry.hecksagon(chapter_name)`) but `bluebook`
620
+ # itself carries no reference to. No hecksagon handed in (an older
621
+ # caller, or a spec that doesn't care) just means no frameworks.mmd
622
+ # — same "nothing to state" skip every other diagram here already
623
+ # takes when its own underlying data is empty.
624
+ def frameworks_diagram(bluebook, hecksagon)
625
+ return nil unless hecksagon
626
+
627
+ lines = hecksagon.framework_members.map { |name| domain_edge(bluebook.name, "attaches", name, dotted: true) }
628
+ lines += bluebook.policies.filter_map(&:target_domain).uniq
629
+ .map { |name| domain_edge(bluebook.name, "reaches across", name, dotted: false) }
630
+ return nil if lines.empty?
631
+
632
+ subject = "#{bluebook.name}'s own declared uses_framework and cross-domain policy targets"
633
+ "#{header(bluebook.name, subject)}flowchart LR\n#{lines.uniq.join("\n")}\n"
634
+ end
635
+
636
+ def domain_edge(from, label, to, dotted:)
637
+ arrow = dotted ? "-.->" : "-->"
638
+ %( #{from}[(#{from})] #{arrow}|#{label}| #{to}[(#{to})])
639
+ end
411
640
  end
412
641
  end
413
642
  end
@@ -1,4 +1,4 @@
1
- require_relative "../runtime/storage_shape"
1
+ require_relative "../ports/persistence"
2
2
  require_relative "../projector"
3
3
 
4
4
  module Hecks
@@ -25,7 +25,20 @@ module Hecks
25
25
 
26
26
  module_function
27
27
 
28
- def call(bluebook:, options: {}) = Runtime::StorageShape.project(bluebook)
28
+ # ADR 0033 — `Runtime::StorageShape` lives in the era persistence
29
+ # plugin now; the `:shape` projection itself stays registered
30
+ # unconditionally (the seam `spec/projector_seam_spec.rb` enforces
31
+ # is "every file here registers," not "every projection always
32
+ # succeeds") but refuses clearly if asked to run with the plugin
33
+ # unloaded, rather than raising on an undefined constant.
34
+ def call(bluebook:, options: {})
35
+ unless Ports::Persistence.plugin?(:era)
36
+ raise "the :shape projection needs the era persistence plugin loaded " \
37
+ "(require \"hecks/ports/persistence/plugins/era\")"
38
+ end
39
+
40
+ Runtime::StorageShape.project(bluebook)
41
+ end
29
42
  end
30
43
  end
31
44
  end
@@ -192,7 +192,8 @@ module Hecks
192
192
  end
193
193
 
194
194
  { verb: fqn(bluebook, aggregate, command, entity), kind: :command,
195
- summary: command.goal, role: command.role, creates: command.creates?,
195
+ summary: command.goal, role: command.role, role_gated: !command.role.to_s.empty?,
196
+ creates: command.creates?,
196
197
  receiver: receiver, legacy_receiver: (receiver == :aggregate ? :id : nil),
197
198
  legacy_arguments: legacy_arguments,
198
199
  refusals: refusals(command, holder), arguments: arguments }
@@ -227,7 +228,15 @@ module Hecks
227
228
  # names it short, which is the same split `shorten` already makes.
228
229
  { verb: [fqn(bluebook, aggregate, operation).sub(/\.[^.]+\z/, ""), port.name, operation.hecks_name].join("."),
229
230
  kind: :command, creates: false, receiver: :aggregate, refusals: [],
231
+ # `role:` HERE IS DESCRIPTIVE TEXT, NOT AN AUTHORIZATION GATE —
232
+ # who calls whom through the port, for `--help`/`verb_help`'s
233
+ # "issued by" line. A port operation never reaches
234
+ # `CommandRules::Authorization#refuse_role_mismatch` (only
235
+ # `CommandInterpreter`/`EntityInterpreter` call it, never the port
236
+ # dispatch path), so `role_gated: false` always, unlike
237
+ # `command_spec` where the same key name means a real one.
230
238
  role: operation.outbound? ? "#{aggregate.hecks_name} asking #{port.name}" : "#{port.name} telling #{aggregate.hecks_name}",
239
+ role_gated: false,
231
240
  summary: port_summary(port, operation), arguments: arguments }
232
241
  end
233
242
 
@@ -1,6 +1,5 @@
1
1
  require "json"
2
- require_relative "../runtime/era_check"
3
- require_relative "../translation/rule_compiler"
2
+ require_relative "../ports/persistence"
4
3
 
5
4
  module Hecks
6
5
  module Projector
@@ -30,7 +29,15 @@ module Hecks
30
29
  # Reuses `Runtime::EraCheck`'s own capability predicates rather than
31
30
  # re-deriving them — the boot-time gate and this export must never
32
31
  # answer differently for the same aggregate.
32
+ #
33
+ # ADR 0033 — `Runtime::EraCheck` lives in the (optional) era
34
+ # persistence plugin now; unloaded, this answers exactly what it
35
+ # already answers for a domain with nothing lineage-capable bound —
36
+ # `capable_aggregates: []` — rather than raising on an undefined
37
+ # constant.
33
38
  def lineage(registry, domain_name)
39
+ return { capable_aggregates: [] } unless Ports::Persistence.plugin?(:era)
40
+
34
41
  bluebook = registry.bluebooks.fetch(domain_name)
35
42
  capable = bluebook.aggregates.select do |aggregate|
36
43
  adapter_name = Runtime::EraCheck.adapter_for(registry, domain_name, aggregate)
@@ -130,7 +137,17 @@ module Hecks
130
137
  # expression` is nil unless this edge rekeys — the bare
131
138
  # `aggregate_id` passthrough head_compiler.rb itself falls back to
132
139
  # for the overwhelming common case.
140
+ # ADR 0033 — `Translation::RuleCompiler` lives in the era plugin;
141
+ # unloaded, there is nothing that can compile this SQL, so this
142
+ # falls back to the bare declared fields (`translation_aggregate`
143
+ # alone) rather than raising on an undefined constant. A consumer
144
+ # embedding this JSON without the era plugin loaded gets the same
145
+ # declared-rules shape, just without precompiled SQL to execute —
146
+ # consistent with there being no mint/audit machinery to run it
147
+ # against either.
133
148
  def compiled_translation_aggregate(aggregate)
149
+ return translation_aggregate(aggregate) unless Ports::Persistence.plugin?(:era)
150
+
134
151
  translation_aggregate(aggregate).merge(
135
152
  compiled_state_expression: Translation::RuleCompiler.compile_rules(aggregate),
136
153
  compiled_id_expression: Translation::RuleCompiler.rekeyed?(aggregate) ? Translation::RuleCompiler.compile_id_expression(aggregate) : nil
@@ -233,6 +233,25 @@ module Hecks
233
233
  # hand whether this is ALREADY a chapter-wide reference before
234
234
  # assuming it's fresh duplication," not as an automatic signal
235
235
  # either way.
236
+ #
237
+ # THE IDENTICAL GAP, ONE LEVEL DOWN: chapter-wide ENTITY-scoped
238
+ # sharing (`EntityBuilder#given`'s own bare form,
239
+ # `docs/implemented/resolution-rules/chapter-entity-given.md`) hits this same wall for
240
+ # the same structural reason — `SafeDepositBox.Visit` still shows
241
+ # as its own "(declared)" owner here even after becoming a bare
242
+ # reference to `Account.LedgerEntry`'s declaration, because a piece
243
+ # resolving a chapter-wide reference still write-throughs the
244
+ # resolved `Given` into its own `@named_givens` (so ITS OWN
245
+ # commands can read it back locally without a second hop). This is
246
+ # not a NEW limitation this feature introduces — it is the exact
247
+ # same IR-cannot-distinguish-declared-from-referenced fact, one
248
+ # scope wider. `bin/query_ir duplicates` confirms this directly:
249
+ # `Account.LedgerEntry (declared)` and `SafeDepositBox.Visit
250
+ # (declared)` both appear under the same `given: "customer is
251
+ # active"` group — verify by hand, same as the aggregate-level
252
+ # case above, before assuming a group naming two pieces under
253
+ # different aggregates is fresh duplication rather than an already-
254
+ # resolved chapter-wide reference.
236
255
  def declaration_count(rules)
237
256
  declared = rules.select { |r| r.location.end_with?(" (declared)") }
238
257
  declared_owners = declared.map { |r| owner_of(r.location) }.to_set
@@ -25,12 +25,24 @@ module Hecks
25
25
  end
26
26
  end
27
27
 
28
+ # M3 — an UNDECLARED (`native`) null policy used to render no
29
+ # `NULLS ...` clause at all here, leaving each dialect's own
30
+ # default to decide: Postgres puts nulls LAST on ASC (and FIRST
31
+ # on DESC), while `#order` above — this same "native" default,
32
+ # for Memory — puts nulls FIRST on ASC (and LAST on DESC), the
33
+ # SQLite convention. Same query, same data, different row order
34
+ # depending only on which adapter ran it. Rendered explicitly
35
+ # here instead, so an undeclared policy means the SAME total
36
+ # order everywhere rather than "whatever this store already does"
37
+ # — matching `#order`'s own default rather than the other way
38
+ # round, since that default is unconditional (Memory/Heki have no
39
+ # dialect to defer to) and SQLite already agrees with it natively.
28
40
  def sql_order(expression, direction, policy)
29
41
  direction = direction.to_s.downcase == "desc" ? "DESC" : "ASC"
30
42
  nulls = case policy&.mode.to_s
31
43
  when "first" then " NULLS FIRST"
32
44
  when "last" then " NULLS LAST"
33
- else ""
45
+ else direction == "DESC" ? " NULLS LAST" : " NULLS FIRST"
34
46
  end
35
47
  "#{expression} #{direction}#{nulls}, id #{direction}"
36
48
  end
@@ -31,7 +31,11 @@ module Hecks
31
31
  # plain row hash); the rest read through whatever each step holds. A
32
32
  # stored nested value object is a plain hash by the time it is read
33
33
  # back, keyed by symbol in memory and by string off a wire decode,
34
- # so both spellings are tried.
34
+ # so both spellings are tried — `key?` first, never `||`, because
35
+ # `||` falls through a genuinely-stored `false` to the OTHER
36
+ # spelling (usually absent) and returns `nil` instead. The seal
37
+ # admits boolean leaves (`SCALAR_PRIMITIVES` below), so a `false`
38
+ # here is a real, held answer, not a missing one.
35
39
  def dig(holder, field)
36
40
  return nil if field.nil?
37
41
 
@@ -40,7 +44,21 @@ module Hecks
40
44
 
41
45
  def read(current, segment)
42
46
  return nil if current.nil?
43
- return current[segment.to_sym] || current[segment] if current.is_a?(Hash)
47
+
48
+ if current.is_a?(Hash)
49
+ sym = segment.to_sym
50
+ return current.key?(sym) ? current[sym] : current[segment]
51
+ end
52
+
53
+ # M5 — "or nil, never raise" is this method's whole contract, and
54
+ # an Array broke it: `Array#[]` demands an Integer index, so
55
+ # `current[segment]` (a String) raised `TypeError` straight
56
+ # through `dig` instead of answering nil. A dotted path stepping
57
+ # INTO a list_of attribute (`where "tags.name" == "x"` against a
58
+ # bare list, rather than each element) has no single member a
59
+ # bare index would name anyway — nil is the honest answer, the
60
+ # same one a dangling reference or a missing key already gets.
61
+ return nil if current.is_a?(Array)
44
62
 
45
63
  current[segment]
46
64
  end
@@ -7,11 +7,13 @@ module Hecks
7
7
  # sibling, not its member. FieldPath walks a SHAPE, which cannot
8
8
  # loop, and answers nil, never raising, because there is nothing
9
9
  # left to say beyond "not found." HopPath walks the REFERENCE
10
- # GRAPH instead, which the language does not guarantee acyclic —
11
- # aggregates only refuse a DIRECT bidirectional pair
12
- # (BluebookBuilder#validate_no_bidirectional_references! explicitly
13
- # declines to take a position on a longer ring) — so a walk here
14
- # needs to say WHY it stopped, not just that it did.
10
+ # GRAPH instead — `BluebookBuilder#validate_no_bidirectional_references!`
11
+ # refuses any reference CYCLE at declaration time (ADR 0025,
12
+ # "References" — widened from a direct pair to any ring, DFS over
13
+ # the chapter's own reference graph), so what's left to guard here
14
+ # is depth, not cycles: MAX_HOPS below, and this module's own
15
+ # `refusal` states for a target the chapter doesn't resolve. A walk
16
+ # here still needs to say WHY it stopped, not just that it did.
15
17
  #
16
18
  # Every method below takes an ATTRIBUTE ARRAY, never a "shape"
17
19
  # object — deliberately, because the two real callers hold their