hecks 2.7.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 (150) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +13 -0
  3. data/lib/hecks/adapters/driven/memory.rb +1 -1
  4. data/lib/hecks/adapters/driven/postgres.rb +1 -1
  5. data/lib/hecks/adapters/driven/prism.rb +1 -1
  6. data/lib/hecks/adapters/driven/sqlite/projection.rb +1 -1
  7. data/lib/hecks/adapters/driven/tenant_provisioner.rb +31 -10
  8. data/lib/hecks/behaviors/expectations.rb +2 -2
  9. data/lib/hecks/bluebook/assembly/contract.rb +1 -1
  10. data/lib/hecks/bluebook/assembly/contracts.rb +5 -5
  11. data/lib/hecks/bluebook/assembly/specializer.rb +2 -2
  12. data/lib/hecks/bluebook/assembly.rb +1 -1
  13. data/lib/hecks/bluebook/behaviour/chapter.rb +5 -2
  14. data/lib/hecks/bluebook/behaviour/read_model.rb +18 -0
  15. data/lib/hecks/bluebook/capabilities.rb +2 -2
  16. data/lib/hecks/bluebook/chapter.rb +1 -1
  17. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +2 -2
  18. data/lib/hecks/bluebook/dsl/binding_proxy.rb +6 -1
  19. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +2 -2
  20. data/lib/hecks/bluebook/dsl/entity_builder.rb +1 -1
  21. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +1 -1
  22. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +14 -9
  23. data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -3
  24. data/lib/hecks/bluebook/dsl/translation_builder.rb +1 -1
  25. data/lib/hecks/bluebook/dsl/world_builder.rb +5 -0
  26. data/lib/hecks/bluebook/meta_validator/judge.rb +3 -3
  27. data/lib/hecks/bluebook/meta_validator/plan.rb +4 -4
  28. data/lib/hecks/bluebook/meta_validator/readings.rb +1 -1
  29. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +2 -2
  30. data/lib/hecks/bluebook/meta_validator/shapes.rb +1 -1
  31. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +11 -10
  32. data/lib/hecks/bluebook/meta_validator.rb +1 -1
  33. data/lib/hecks/bluebook/model_check/client_profile.rb +4 -70
  34. data/lib/hecks/bluebook/model_check.rb +12 -13
  35. data/lib/hecks/cache_dir.rb +103 -0
  36. data/lib/hecks/cli/document.rb +104 -0
  37. data/lib/hecks/cli/ir.rb +47 -0
  38. data/lib/hecks/cli/mcp.rb +32 -0
  39. data/lib/hecks/cli/mcp_door.rb +461 -0
  40. data/lib/hecks/cli/model_check.rb +270 -0
  41. data/lib/hecks/cli/project_cli.rb +123 -0
  42. data/lib/hecks/cli/project_diagrams.rb +43 -0
  43. data/lib/hecks/cli/run.rb +219 -0
  44. data/lib/hecks/cli/smoke_test.rb +59 -0
  45. data/lib/hecks/cli/stores.rb +54 -0
  46. data/lib/hecks/cli.rb +166 -0
  47. data/lib/hecks/facade/handle.rb +2 -2
  48. data/lib/hecks/facade/surface/aggregate_door.rb +1 -1
  49. data/lib/hecks/language/bluebook/vocabulary.bluebook +16 -0
  50. data/lib/hecks/mcp_door_scope.rb +176 -0
  51. data/lib/hecks/ports/persistence/append_only.rb +1 -1
  52. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +1 -1
  53. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +2 -2
  54. data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +2 -2
  55. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +2 -2
  56. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +1 -1
  57. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +1 -1
  58. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +12 -6
  59. data/lib/hecks/ports/persistence/state_codec.rb +1 -1
  60. data/lib/hecks/projections/deploy/fargate.rb +1 -1
  61. data/lib/hecks/projections/deploy/lambda.rb +3 -3
  62. data/lib/hecks/projections/reference.rb +7 -1
  63. data/lib/hecks/projector/exporter.rb +4 -4
  64. data/lib/hecks/projector/target.rb +1 -1
  65. data/lib/hecks/release/runner/ci_publisher.rb +96 -0
  66. data/lib/hecks/release/runner/commands.rb +68 -0
  67. data/lib/hecks/release/runner/console.rb +52 -0
  68. data/lib/hecks/release/runner/gem_publisher.rb +44 -0
  69. data/lib/hecks/release/runner/git.rb +48 -0
  70. data/lib/hecks/release/runner/npm_publisher.rb +89 -0
  71. data/lib/hecks/release/runner/preflight.rb +119 -0
  72. data/lib/hecks/release/runner/published.rb +55 -0
  73. data/lib/hecks/release/runner/tagger.rb +87 -0
  74. data/lib/hecks/release/runner.rb +244 -0
  75. data/lib/hecks/router/namespace_installer.rb +1 -1
  76. data/lib/hecks/runtime/command_interpreter.rb +3 -3
  77. data/lib/hecks/runtime/command_rules/admissibility.rb +2 -2
  78. data/lib/hecks/runtime/entity_element.rb +1 -1
  79. data/lib/hecks/runtime/entity_interpreter.rb +1 -1
  80. data/lib/hecks/runtime/invocation.rb +1 -1
  81. data/lib/hecks/runtime/loader.rb +1 -1
  82. data/lib/hecks/runtime/outbox.rb +1 -1
  83. data/lib/hecks/runtime/reaction_invocation.rb +3 -3
  84. data/lib/hecks/runtime/read_model_interpreter.rb +37 -15
  85. data/lib/hecks/runtime/reference_hop.rb +4 -4
  86. data/lib/hecks/runtime/registry/verification.rb +11 -11
  87. data/lib/hecks/runtime/registry.rb +8 -4
  88. data/lib/hecks/runtime/saga_interpreter.rb +1 -1
  89. data/lib/hecks/runtime/value/coercion.rb +3 -3
  90. data/lib/hecks/runtime/value/entity_list_coercion.rb +1 -1
  91. data/lib/hecks/storehouse.rb +18 -12
  92. data/lib/hecks/version.rb +1 -1
  93. data/lib/hecks/vocabulary.rb +5 -0
  94. data/lib/hecks.rb +6 -1
  95. metadata +28 -58
  96. data/lib/hecks/bench/cli.rb +0 -74
  97. data/lib/hecks/bench/environment.rb +0 -100
  98. data/lib/hecks/bench/postgres_probe.rb +0 -41
  99. data/lib/hecks/bench/report.rb +0 -116
  100. data/lib/hecks/bench/ruby_runner.rb +0 -151
  101. data/lib/hecks/bench/run.rb +0 -55
  102. data/lib/hecks/bench/rust_runner.rb +0 -165
  103. data/lib/hecks/bench/stats.rb +0 -75
  104. data/lib/hecks/bench/suite.rb +0 -172
  105. data/lib/hecks/bench/workload.rb +0 -137
  106. data/lib/hecks/bench.rb +0 -28
  107. data/lib/hecks/codemod.rb +0 -443
  108. data/lib/hecks/corpus.rb +0 -479
  109. data/lib/hecks/doc/reference.rb +0 -585
  110. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -684
  111. data/lib/hecks/fuzzing/combination_miner.rb +0 -178
  112. data/lib/hecks/fuzzing/concurrent_dispatch.rb +0 -469
  113. data/lib/hecks/fuzzing/coverage_campaign.rb +0 -161
  114. data/lib/hecks/fuzzing/differential.rb +0 -192
  115. data/lib/hecks/fuzzing/domain_generator.rb +0 -871
  116. data/lib/hecks/fuzzing/era_boundary.rb +0 -154
  117. data/lib/hecks/fuzzing/form_census.rb +0 -285
  118. data/lib/hecks/fuzzing/generated_domain_check.rb +0 -171
  119. data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -125
  120. data/lib/hecks/fuzzing/isolated_boot.rb +0 -658
  121. data/lib/hecks/fuzzing/nondeterministic.rb +0 -79
  122. data/lib/hecks/fuzzing/persistence_parity.rb +0 -253
  123. data/lib/hecks/fuzzing/properties/corrections.rb +0 -125
  124. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +0 -688
  125. data/lib/hecks/fuzzing/properties/guards.rb +0 -398
  126. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +0 -399
  127. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +0 -137
  128. data/lib/hecks/fuzzing/properties/outbox.rb +0 -180
  129. data/lib/hecks/fuzzing/properties/querying.rb +0 -260
  130. data/lib/hecks/fuzzing/properties.rb +0 -267
  131. data/lib/hecks/fuzzing/qa_settings.rb +0 -164
  132. data/lib/hecks/fuzzing/replay.rb +0 -930
  133. data/lib/hecks/fuzzing/rotation_priority.rb +0 -114
  134. data/lib/hecks/fuzzing/rust_gap_manifest.rb +0 -139
  135. data/lib/hecks/fuzzing/self_consistency.rb +0 -825
  136. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +0 -532
  137. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +0 -171
  138. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +0 -127
  139. data/lib/hecks/fuzzing/sequence_generator/picker.rb +0 -116
  140. data/lib/hecks/fuzzing/sequence_generator/query_binding.rb +0 -138
  141. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +0 -344
  142. data/lib/hecks/fuzzing/sequence_generator.rb +0 -311
  143. data/lib/hecks/fuzzing/shrinker.rb +0 -309
  144. data/lib/hecks/fuzzing/structural_skips.rb +0 -53
  145. data/lib/hecks/fuzzing/sweep_depth.rb +0 -61
  146. data/lib/hecks/fuzzing/target_capabilities.rb +0 -255
  147. data/lib/hecks/fuzzing/value_generator.rb +0 -340
  148. data/lib/hecks/fuzzing.rb +0 -23
  149. data/lib/hecks/grammar/evolve.rb +0 -524
  150. data/lib/hecks/query_ir.rb +0 -482
@@ -541,7 +541,7 @@ module Hecks
541
541
  # every Paging-attached word (limit/offset/cursor/nulls) because
542
542
  # something called it inside this exact window.
543
543
  #
544
- # SyntaxBoot.call no longer uses this. Gating its cache on "the
544
+ # SyntaxBoot.call does not use this. Gating its cache on "the
545
545
  # whole registry is finished" meant nothing was cached for the
546
546
  # entire window, and the window is not narrow — every word routed
547
547
  # through `word_gate_dispatch` while the language judged itself
@@ -15,60 +15,29 @@ module Hecks
15
15
  #
16
16
  # ## The rules
17
17
  #
18
- # - `:client_group_by_row_drop` — a `group_by` whose fields do not include
19
- # the grouped aggregate's whole identity. Rows sharing a key path are
20
- # collapsed to the first, on every adapter (docs/decisions/0061).
21
18
  # - `:client_native_read_model` — a read model the SQLite projection
22
19
  # answers natively, where every other path answers it in process
23
20
  # (docs/1.0-readiness.md, "Known gaps at 1.0", item 2).
24
- # - `:client_dotted_compute_source` — an era translation `compute` whose
25
- # source is a dotted path, which never fires
26
- # (`Translation::RuleCompiler.compile_compute`).
27
21
  module ClientProfile
28
22
  # Adapters whose Ruby class implements `query_read_model`, which is what
29
23
  # makes `ReadModelInterpreter#project` take the native path. The spec
30
24
  # checks this list against the adapter classes themselves.
31
25
  NATIVE_READ_MODEL_ADAPTERS = %w[SqliteProjection].freeze
32
26
 
33
- GROUP_BY_TRACKER = "docs/decisions/0061-query-dsl-aggregation-count-sum-group-by.md".freeze
34
27
  NATIVE_READ_MODEL_TRACKER = "docs/1.0-readiness.md (Known gaps at 1.0, item 2)".freeze
35
- DOTTED_COMPUTE_TRACKER = "Translation::RuleCompiler.compile_compute, and the pending example " \
36
- "\"applies the SQL of a compute whose source is a dotted member\" in " \
37
- "spec/adapters/driven/postgres_era/migration_data_safety_spec.rb".freeze
38
28
 
39
29
  module_function
40
30
 
41
- # Runs every client-profile rule over one chapter and its translations.
31
+ # Runs every client-profile rule over one chapter.
42
32
  #
43
33
  # @param bluebook [Bluebook::Chapter] the assembled chapter to check
44
34
  # @param hecksagon [Bluebook::Hecksagon, nil] the chapter's sibling wiring file; without
45
35
  # one no aggregate is projected, so the native-read-model rule has nothing to find
46
- # @param translations [Array<Bluebook::Translation>] the data translations declared for
47
- # this chapter's domain
48
36
  # @return [Array<ModelCheck::Finding>] one error-severity finding per construct refused
49
- def call(bluebook, hecksagon: nil, translations: [])
37
+ def call(bluebook, hecksagon: nil)
50
38
  bluebook.read_models.flat_map do |model|
51
- group_by_findings(bluebook, model) + native_read_model_findings(model, hecksagon)
52
- end + dotted_compute_findings(translations)
53
- end
54
-
55
- # Refuses a `group_by` that cannot prove each of its key paths names one row.
56
- #
57
- # The interpreter's `nest` keeps `stripped.first` per key path, whichever adapter
58
- # supplied the rows, because a `group_by` model never reaches the native path. Grouping
59
- # by the aggregate's whole identity is the one shape that cannot collide.
60
- #
61
- # @param bluebook [Bluebook::Chapter] the chapter the read model is declared in
62
- # @param model [Bluebook::ReadModel] the read model to inspect
63
- # @return [Array<ModelCheck::Finding>] one finding, or `[]` when `model` declares no
64
- # `group_by` or groups by its aggregate's whole identity
65
- def group_by_findings(bluebook, model)
66
- return [] if model.group_by.empty? || groups_by_identity?(bluebook, model)
67
-
68
- [finding(:client_group_by_row_drop, model.name,
69
- "group_by #{model.group_by_fields.map(&:inspect).join(', ')} does not cover the grouped " \
70
- "aggregate's whole identity, so rows sharing a key path are silently reduced to the " \
71
- "first. Tracked in #{GROUP_BY_TRACKER}.")]
39
+ native_read_model_findings(model, hecksagon)
40
+ end
72
41
  end
73
42
 
74
43
  # Refuses a read model that the SQLite projection would answer natively.
@@ -95,26 +64,6 @@ module Hecks
95
64
  "is not, and nothing checks the two agree. Tracked in #{NATIVE_READ_MODEL_TRACKER}.")]
96
65
  end
97
66
 
98
- # Refuses a translation `compute` whose source names a member of a value object.
99
- #
100
- # `compile_compute` guards the compute with `__s ? '<from>'`, a top-level key test, so a
101
- # dotted source never matches, the mint succeeds and the record keeps its old value.
102
- #
103
- # @param translations [Array<Bluebook::Translation>] the translations to inspect
104
- # @return [Array<ModelCheck::Finding>] one finding per dotted-source compute
105
- def dotted_compute_findings(translations)
106
- translations.flat_map do |translation|
107
- translation.aggregates.flat_map do |aggregate|
108
- aggregate.computes.select { |compute| compute.from.to_s.include?(".") }.map do |compute|
109
- finding(:client_dotted_compute_source, "#{translation.domain}::#{aggregate.name}",
110
- "compute #{compute.from.to_s.inspect} has a dotted source, which the compiled SQL " \
111
- "never matches, so the mint succeeds and leaves the old value in place. " \
112
- "Tracked in #{DOTTED_COMPUTE_TRACKER}.")
113
- end
114
- end
115
- end
116
- end
117
-
118
67
  # Reports whether a model's shape sends it down the native path in `#project`.
119
68
  #
120
69
  # @param model [Bluebook::ReadModel] the read model to inspect
@@ -123,21 +72,6 @@ module Hecks
123
72
  !model.reference_target.nil? && model.group_by.empty? && !model.count? && !model.median_field
124
73
  end
125
74
 
126
- # Reports whether a `group_by` names every identity path of the aggregate it groups.
127
- #
128
- # @param bluebook [Bluebook::Chapter] the chapter the read model is declared in
129
- # @param model [Bluebook::ReadModel] a read model that declares a `group_by`
130
- # @return [Boolean] false when the many-side aggregate is not a top-level aggregate or
131
- # declares no identity, since uniqueness cannot then be shown
132
- def groups_by_identity?(bluebook, model)
133
- target = model.aggregate_heads.find { |head| head[:many] }
134
- aggregate = target && bluebook.aggregate(target[:aggregate])
135
- return false unless aggregate
136
-
137
- identity = Array(aggregate.identity_heads).map(&:to_sym)
138
- !identity.empty? && (identity - model.group_by_fields.map(&:to_sym)).empty?
139
- end
140
-
141
75
  # Builds one error-severity finding of this profile.
142
76
  #
143
77
  # @param kind [Symbol] the finding's kind, one of the `:client_*` rules above
@@ -25,7 +25,7 @@ module Hecks
25
25
  # **A finding shipped, not silenced** — the coverage-gate idiom, empty
26
26
  # allowlists enforced both directions (spec/model_check_spec.rb holds
27
27
  # this exact table: an error the checker reports and this does not
28
- # name is a regression, an entry the checker no longer reports is
28
+ # name is a regression, an entry the checker does not report is
29
29
  # stale and must be deleted). bin/model_check reads this same
30
30
  # constant, so the tool and the spec can never drift apart.
31
31
  #
@@ -44,9 +44,9 @@ module Hecks
44
44
  # was a `state "x"` line never named by any handler's own from:/to:, a
45
45
  # pure declaration-drift artifact. States are derived from the
46
46
  # transitions that name them now (ProcessManagerBuilder#derived_
47
- # states), so a state nothing ever transitions into or out of no
48
- # longer exists to be unreachable — the finding that entry allowlisted
49
- # cannot occur any more, by construction.
47
+ # states), so a state nothing ever transitions into or out of does
48
+ # not exist to be unreachable — the finding that entry allowlisted
49
+ # cannot occur, by construction.
50
50
  #
51
51
  # "banking"/NotifyOnClosure, FlagKeyReturn — gone from here, moved
52
52
  # to banking. `across "Notifications"` names a domain that does not
@@ -145,15 +145,11 @@ module Hecks
145
145
  # findings for constructs known to answer wrongly without refusing;
146
146
  # `nil` (the default) adds nothing and leaves every other finding as
147
147
  # it was
148
- # @param translations [Array<Bluebook::Translation>] the data
149
- # translations declared for this chapter's domain, read only by the
150
- # `:client` profile — they live on the registry, not the chapter, so
151
- # the caller supplies them the way it supplies `hecksagon`
152
148
  # @return [Array<Finding>] every finding this bluebook triggers,
153
149
  # across its lifecycles, sagas, policies, and Rust-reserved names
154
150
  # @raise [ArgumentError] if `profile` is neither `nil` nor `:client`
155
151
  def call(bluebook, hecksagon: nil, known_domains: nil, global_emitted_events: nil, rust_target: false, strict: false,
156
- profile: nil, translations: [])
152
+ profile: nil)
157
153
  unless profile.nil? || PROFILES.include?(profile)
158
154
  raise ArgumentError, "unknown profile #{profile.inspect} (known: #{PROFILES.inspect})"
159
155
  end
@@ -170,7 +166,7 @@ module Hecks
170
166
  findings.concat(rust_reserved_name_findings(domain_name: bluebook.name,
171
167
  aggregate_names: bluebook.aggregates.map(&:hecks_name),
172
168
  rust_target: rust_target, strict: strict))
173
- findings.concat(ClientProfile.call(bluebook, hecksagon: hecksagon, translations: translations)) if profile == :client
169
+ findings.concat(ClientProfile.call(bluebook, hecksagon: hecksagon)) if profile == :client
174
170
  findings
175
171
  end
176
172
 
@@ -659,6 +655,9 @@ module Hecks
659
655
  # sibling wiring file, if the caller loaded one
660
656
  # @param known_domains [Set<String>, nil] every bluebook/hecksagon
661
657
  # name the caller has booted anywhere in this corpus scan
658
+ # @param global_emitted_events [Set<String>, nil] every event name emitted anywhere in the
659
+ # corpus, consulted only as a fallback for a `translates` reaction; nil when the caller
660
+ # has no cross-domain set to offer
662
661
  # @return [Array<Finding>] every `deaf_policy`/`unknown_trigger`
663
662
  # finding this policy triggers, or `cross_domain_policy_findings`'s
664
663
  # own return for a cross-domain policy
@@ -680,12 +679,12 @@ module Hecks
680
679
  # (`across "Compliance"`), which exits this method one line above
681
680
  # before the mismatch is ever reached.
682
681
  # `global_emitted_events` — a `translates` (hecksagon-level)
683
- # reaction is BUILT as the exact same same-domain-target `Policy`
682
+ # reaction is built as the exact same same-domain-target `Policy`
684
683
  # an ordinary `policy` block is (no `target_domain`, so it never
685
684
  # reaches `cross_domain_policy_findings` above), but its whole
686
- # point is to react to a FOREIGN domain's own event — this
685
+ # point is to react to a foreign domain's own event — this
687
686
  # domain's own `emitted_events(bluebook)` was never going to
688
- # contain it. Checked only as a FALLBACK, after the local check
687
+ # contain it. Checked only as a fallback, after the local check
689
688
  # already failed, so a real same-domain typo still gets flagged
690
689
  # exactly as before whenever the caller has no cross-domain set
691
690
  # to offer (nil — every existing call site, unchanged) or the
@@ -0,0 +1,103 @@
1
+ require "etc"
2
+ require "fileutils"
3
+ require "tmpdir"
4
+
5
+ module Hecks
6
+ # The one place the runtime's own scratch files live, outside the gem.
7
+ #
8
+ # The syntax-boot cache and the Storehouse audit log are written on a
9
+ # normal run, so they cannot sit under the gem's own directory: an
10
+ # installed gem is read-only, and a checkout's `tmp/` is not a place a
11
+ # library gets to assume. Both ask this module for a subdirectory instead.
12
+ #
13
+ # ## Where the root is
14
+ #
15
+ # The first of these that can be made a private directory wins:
16
+ #
17
+ # 1. `$XDG_CACHE_HOME/hecks`, when `XDG_CACHE_HOME` is set to an absolute path
18
+ # 2. `~/.cache/hecks`
19
+ # 3. `<system temp dir>/hecks-<uid>`
20
+ #
21
+ # ## Private, or not used
22
+ #
23
+ # The syntax-boot cache is read back with `Marshal.load`, so a directory
24
+ # another account can write to is a way to run code as this one. A
25
+ # candidate is accepted only if it is owned by the current user and
26
+ # writable by nobody else; the shared temp directory in particular is
27
+ # where a predictable name can be planted by someone else. When no candidate
28
+ # qualifies, a fresh per-process directory stands in: the cache still
29
+ # works within the process and is never shared with a stranger.
30
+ module CacheDir
31
+ module_function
32
+
33
+ # The directory a named piece of scratch state lives in.
34
+ #
35
+ # @param name [String] the subdirectory, such as `"storehouse"`
36
+ # @return [String] the absolute path of `name` under the cache root; the
37
+ # directory itself is created by whoever writes to it
38
+ def path(name)
39
+ File.join(root, name)
40
+ end
41
+
42
+ # The cache root, resolved once per process.
43
+ #
44
+ # @return [String] the absolute path of the directory every `path` lives under
45
+ def root
46
+ @root ||= resolve
47
+ end
48
+
49
+ # Forgets the resolved root so the next call resolves again.
50
+ #
51
+ # @return [void]
52
+ def reset!
53
+ @root = nil
54
+ end
55
+
56
+ # Picks the cache root from the environment.
57
+ #
58
+ # @param env [Hash{String => String}] the environment to read
59
+ # `XDG_CACHE_HOME` and `HOME` from
60
+ # @param tmpdir [String] the system temp directory
61
+ # @param uid [Integer] the current user id, used in the temp fallback's name
62
+ # @return [String] the absolute path of the first candidate that is a
63
+ # private directory owned by this user, else a per-process directory
64
+ def resolve(env: ENV, tmpdir: Dir.tmpdir, uid: Process.uid)
65
+ candidates(env, tmpdir, uid).each do |candidate|
66
+ return candidate if private_dir?(candidate)
67
+ end
68
+ Dir.mktmpdir("hecks-", tmpdir)
69
+ rescue StandardError
70
+ File.join(tmpdir, "hecks-#{uid}")
71
+ end
72
+
73
+ # The roots to try, most preferred first.
74
+ #
75
+ # @param env [Hash{String => String}] the environment
76
+ # @param tmpdir [String] the system temp directory
77
+ # @param uid [Integer] the current user id
78
+ # @return [Array<String>] absolute candidate paths
79
+ def candidates(env, tmpdir, uid)
80
+ xdg = env["XDG_CACHE_HOME"].to_s
81
+ home = env["HOME"].to_s
82
+ list = []
83
+ list << File.join(xdg, "hecks") if File.absolute_path?(xdg)
84
+ list << File.join(home, ".cache", "hecks") if File.absolute_path?(home)
85
+ list << File.join(tmpdir, "hecks-#{uid}")
86
+ end
87
+ private_class_method :candidates
88
+
89
+ # Creates `dir` if needed and says whether it is private to this user.
90
+ #
91
+ # @param dir [String] the candidate directory
92
+ # @return [Boolean] true when `dir` is a directory owned by this user that
93
+ # no other user can write to; false on any failure
94
+ def private_dir?(dir)
95
+ FileUtils.mkdir_p(dir, mode: 0o700)
96
+ stat = File.stat(dir)
97
+ stat.directory? && stat.owned? && stat.mode.nobits?(0o022) && File.writable?(dir)
98
+ rescue StandardError
99
+ false
100
+ end
101
+ private_class_method :private_dir?
102
+ end
103
+ end
@@ -0,0 +1,104 @@
1
+ require_relative "../../hecks"
2
+
3
+ module Hecks
4
+ module CLI
5
+ # The command behind `bin/docs`, `bin/narrate`, `hecks docs` and `hecks narrate`:
6
+ # one domain's document, projected from its own bluebook, to stdout.
7
+ #
8
+ # `:docs` answers the usage document a console's `<Domain>.docs` prints; `:narrate`
9
+ # answers the same chapter read back in English, for the subject-matter expert
10
+ # (`Projector::NarrateProjector`). Nothing is written for the caller: redirect it,
11
+ # so the caller decides where a generated file lands and git decides whether it
12
+ # drifted.
13
+ #
14
+ # ## Every failure is a sentence
15
+ #
16
+ # A misspelled aggregate refuses by name rather than printing nothing and exiting
17
+ # 0. A caller cannot tell an empty document from an empty domain, so this refuses
18
+ # instead and names what is actually there.
19
+ #
20
+ # ## Standing in a domain is enough
21
+ #
22
+ # `Adapters::Folder#domain_root` walks up for the nearest `.hecksagon`, which makes
23
+ # the first argument ambiguous. The rule: a directory is a domain, anything else
24
+ # is an aggregate name, and the two-argument form says which is which.
25
+ module Document
26
+ # Build output and other trees under a root that hold no domain a caller means.
27
+ # Matched against the path relative to the root, because a substring test
28
+ # against an absolute path tests wherever somebody happened to clone.
29
+ IGNORED = %r{\A(rust|deploy|tmp|coverage)/}
30
+
31
+ module_function
32
+
33
+ # Prints `projection` for the domain and optional aggregate `argv` names.
34
+ #
35
+ # @param argv [Array<String>] an optional domain directory, then an optional
36
+ # aggregate name
37
+ # @param projection [Symbol] `:docs` or `:narrate`
38
+ # @param program [String] the name the usage message calls this command by
39
+ # @param root [String] the directory whose domains are listed when no domain
40
+ # is found
41
+ # @return [void]
42
+ # @raise [SystemExit] when no domain is found, it cannot boot, it loads no
43
+ # bluebook, or the aggregate does not exist
44
+ def call(argv, projection:, program:, root:)
45
+ here = Adapters::Folder.new.domain_root
46
+ path, aggregate = target(argv, here)
47
+ refuse_without_domain(program, root) unless path
48
+
49
+ begin
50
+ runtime = Hecks.boot(path, install_facade: false)
51
+ rescue StandardError => e
52
+ abort "cannot read #{path}: #{e.message.lines.first.strip}"
53
+ end
54
+
55
+ # The domain's own chapter, not a framework member it attached: insertion
56
+ # order, the same distinction `Runtime::Loader.dispatcher_for` draws.
57
+ bluebook = runtime.registry.bluebooks.values.first
58
+ abort "#{path} loaded no bluebook" unless bluebook
59
+
60
+ begin
61
+ puts Projector.call(projection, bluebook: bluebook, options: aggregate ? { aggregate: aggregate } : {})
62
+ rescue Runtime::NotFound => e
63
+ abort e.message
64
+ end
65
+ end
66
+
67
+ # Lists every directory under `root` that `Hecks.boot` would accept.
68
+ #
69
+ # Found by their wiring, not their chapters: a `.hecksagon` is what says "this
70
+ # is a domain, and here is how its aggregates are stored", while most
71
+ # `.bluebook` files (era translations, the language's own grammar,
72
+ # `spec/fixtures`) are not domains anybody boots.
73
+ #
74
+ # @param root [String] the directory to search under
75
+ # @return [Array<String>] domain directory paths relative to `root`,
76
+ # deduplicated and sorted
77
+ def domains(root)
78
+ Dir.glob(File.join(root, "**/*.hecksagon"))
79
+ .map { |path| File.basename(File.dirname(path)) == "bluebook" ? File.dirname(path, 2) : File.dirname(path) }
80
+ .map { |path| path.delete_prefix("#{root}/") }
81
+ .grep_v(IGNORED)
82
+ .uniq.sort
83
+ end
84
+
85
+ # @api private
86
+ def target(argv, here)
87
+ if argv.empty? then [here, nil]
88
+ elsif argv.length > 1 then [argv[0], argv[1]]
89
+ elsif File.directory?(argv[0]) then [argv[0], nil]
90
+ else [here, argv[0]]
91
+ end
92
+ end
93
+
94
+ # @api private
95
+ def refuse_without_domain(program, root)
96
+ warn "usage: #{program} [domain-path] [aggregate]"
97
+ warn ""
98
+ warn "No bluebook here — #{Dir.pwd} is not inside a domain. Name one:"
99
+ domains(root).each { |candidate| warn " #{candidate}" }
100
+ exit 1
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,47 @@
1
+ require_relative "../../hecks"
2
+ # ADR 0033 — a domain wired to PostgresEra needs this plugin loaded
3
+ # explicitly; the era/lineage subsystem does not load with core.
4
+ require_relative "../ports/persistence/plugins/era"
5
+
6
+ module Hecks
7
+ module CLI
8
+ # The command behind `bin/ir` and `hecks ir`: a booted domain's IR as JSON, the
9
+ # same `to_h` the golden specs pin and `StorageShape` hashes into an era, for
10
+ # reading rather than asserting on.
11
+ module Ir
12
+ module_function
13
+
14
+ # Prints the IR `argv` asks for.
15
+ #
16
+ # `--meta` prints the language's own IR, the chapters under
17
+ # `lib/hecks/language/bluebook/` merged into one. It is reached through
18
+ # `Bluebook::MetaValidator.grammar_registry`, the real boot sequence, so it
19
+ # cannot drift from what `MetaValidator` itself loads.
20
+ #
21
+ # @param argv [Array<String>] a domain path and `--translations`, or `--meta`
22
+ # @param program [String] the name the usage message calls this command by
23
+ # @param default_domain [String, nil] the domain booted when `argv` names none;
24
+ # nil makes a domain argument required
25
+ # @return [void]
26
+ # @raise [SystemExit] when no domain is named and there is no default
27
+ def call(argv, program:, default_domain: nil)
28
+ argv = argv.dup
29
+ if argv.delete("--meta")
30
+ require_relative "../bluebook/meta_validator"
31
+ puts Projector::Exporter.json(Bluebook::MetaValidator.grammar_registry)
32
+ return
33
+ end
34
+
35
+ translations_only = argv.delete("--translations")
36
+ domain = argv.shift || default_domain or abort "usage: #{program} <domain> [--translations] | #{program} --meta"
37
+ runtime = Hecks.boot(domain)
38
+
39
+ if translations_only
40
+ puts Projector::Exporter.translations_json(runtime.registry)
41
+ else
42
+ puts Projector::Exporter.json(runtime.registry)
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,32 @@
1
+ require_relative "../mcp_stdio_guard"
2
+
3
+ module Hecks
4
+ module CLI
5
+ # The command behind `bin/hecks_mcp_door` and `hecks mcp`: the MCP door onto the
6
+ # storehouse bus, over stdio only.
7
+ #
8
+ # This file loads nothing but `McpStdioGuard`, so the transport check runs before
9
+ # the framework does. The door itself is `McpDoor`, required only once the check
10
+ # passes.
11
+ module Mcp
12
+ # The server name the guard's refusals and warnings carry.
13
+ SERVER = "hecks-mcp-door".freeze
14
+
15
+ module_function
16
+
17
+ # Refuses any transport but stdio, then serves JSON-RPC requests from stdin
18
+ # until it closes.
19
+ #
20
+ # @param argv [Array<String>] the arguments after the command; only `--stdio`
21
+ # is accepted
22
+ # @return [void]
23
+ # @raise [SystemExit] with `McpStdioGuard::EXIT_STATUS` when the process is not
24
+ # set up for stdio
25
+ def call(argv)
26
+ McpStdioGuard.enforce_stdio!(server: SERVER, argv: argv)
27
+ require_relative "mcp_door"
28
+ McpDoor.serve
29
+ end
30
+ end
31
+ end
32
+ end