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.
- checksums.yaml +4 -4
- data/exe/hecks +13 -0
- data/lib/hecks/adapters/driven/memory.rb +1 -1
- data/lib/hecks/adapters/driven/postgres.rb +1 -1
- data/lib/hecks/adapters/driven/prism.rb +1 -1
- data/lib/hecks/adapters/driven/sqlite/projection.rb +1 -1
- data/lib/hecks/adapters/driven/tenant_provisioner.rb +31 -10
- data/lib/hecks/behaviors/expectations.rb +2 -2
- data/lib/hecks/bluebook/assembly/contract.rb +1 -1
- data/lib/hecks/bluebook/assembly/contracts.rb +5 -5
- data/lib/hecks/bluebook/assembly/specializer.rb +2 -2
- data/lib/hecks/bluebook/assembly.rb +1 -1
- data/lib/hecks/bluebook/behaviour/chapter.rb +5 -2
- data/lib/hecks/bluebook/behaviour/read_model.rb +18 -0
- data/lib/hecks/bluebook/capabilities.rb +2 -2
- data/lib/hecks/bluebook/chapter.rb +1 -1
- data/lib/hecks/bluebook/dsl/aggregate_builder.rb +2 -2
- data/lib/hecks/bluebook/dsl/binding_proxy.rb +6 -1
- data/lib/hecks/bluebook/dsl/bluebook_builder.rb +2 -2
- data/lib/hecks/bluebook/dsl/entity_builder.rb +1 -1
- data/lib/hecks/bluebook/dsl/generic_dispatch.rb +1 -1
- data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +14 -9
- data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -3
- data/lib/hecks/bluebook/dsl/translation_builder.rb +1 -1
- data/lib/hecks/bluebook/dsl/world_builder.rb +5 -0
- data/lib/hecks/bluebook/meta_validator/judge.rb +3 -3
- data/lib/hecks/bluebook/meta_validator/plan.rb +4 -4
- data/lib/hecks/bluebook/meta_validator/readings.rb +1 -1
- data/lib/hecks/bluebook/meta_validator/reconstruction.rb +2 -2
- data/lib/hecks/bluebook/meta_validator/shapes.rb +1 -1
- data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +11 -10
- data/lib/hecks/bluebook/meta_validator.rb +1 -1
- data/lib/hecks/bluebook/model_check/client_profile.rb +4 -70
- data/lib/hecks/bluebook/model_check.rb +12 -13
- data/lib/hecks/cache_dir.rb +103 -0
- data/lib/hecks/cli/document.rb +104 -0
- data/lib/hecks/cli/ir.rb +47 -0
- data/lib/hecks/cli/mcp.rb +32 -0
- data/lib/hecks/cli/mcp_door.rb +461 -0
- data/lib/hecks/cli/model_check.rb +270 -0
- data/lib/hecks/cli/project_cli.rb +123 -0
- data/lib/hecks/cli/project_diagrams.rb +43 -0
- data/lib/hecks/cli/run.rb +219 -0
- data/lib/hecks/cli/smoke_test.rb +59 -0
- data/lib/hecks/cli/stores.rb +54 -0
- data/lib/hecks/cli.rb +166 -0
- data/lib/hecks/facade/handle.rb +2 -2
- data/lib/hecks/facade/surface/aggregate_door.rb +1 -1
- data/lib/hecks/language/bluebook/vocabulary.bluebook +16 -0
- data/lib/hecks/mcp_door_scope.rb +176 -0
- data/lib/hecks/ports/persistence/append_only.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +2 -2
- data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +2 -2
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +2 -2
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +12 -6
- data/lib/hecks/ports/persistence/state_codec.rb +1 -1
- data/lib/hecks/projections/deploy/fargate.rb +1 -1
- data/lib/hecks/projections/deploy/lambda.rb +3 -3
- data/lib/hecks/projections/reference.rb +7 -1
- data/lib/hecks/projector/exporter.rb +4 -4
- data/lib/hecks/projector/target.rb +1 -1
- data/lib/hecks/release/runner/ci_publisher.rb +96 -0
- data/lib/hecks/release/runner/commands.rb +68 -0
- data/lib/hecks/release/runner/console.rb +52 -0
- data/lib/hecks/release/runner/gem_publisher.rb +44 -0
- data/lib/hecks/release/runner/git.rb +48 -0
- data/lib/hecks/release/runner/npm_publisher.rb +89 -0
- data/lib/hecks/release/runner/preflight.rb +119 -0
- data/lib/hecks/release/runner/published.rb +55 -0
- data/lib/hecks/release/runner/tagger.rb +87 -0
- data/lib/hecks/release/runner.rb +244 -0
- data/lib/hecks/router/namespace_installer.rb +1 -1
- data/lib/hecks/runtime/command_interpreter.rb +3 -3
- data/lib/hecks/runtime/command_rules/admissibility.rb +2 -2
- data/lib/hecks/runtime/entity_element.rb +1 -1
- data/lib/hecks/runtime/entity_interpreter.rb +1 -1
- data/lib/hecks/runtime/invocation.rb +1 -1
- data/lib/hecks/runtime/loader.rb +1 -1
- data/lib/hecks/runtime/outbox.rb +1 -1
- data/lib/hecks/runtime/reaction_invocation.rb +3 -3
- data/lib/hecks/runtime/read_model_interpreter.rb +37 -15
- data/lib/hecks/runtime/reference_hop.rb +4 -4
- data/lib/hecks/runtime/registry/verification.rb +11 -11
- data/lib/hecks/runtime/registry.rb +8 -4
- data/lib/hecks/runtime/saga_interpreter.rb +1 -1
- data/lib/hecks/runtime/value/coercion.rb +3 -3
- data/lib/hecks/runtime/value/entity_list_coercion.rb +1 -1
- data/lib/hecks/storehouse.rb +18 -12
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +5 -0
- data/lib/hecks.rb +6 -1
- metadata +28 -58
- data/lib/hecks/bench/cli.rb +0 -74
- data/lib/hecks/bench/environment.rb +0 -100
- data/lib/hecks/bench/postgres_probe.rb +0 -41
- data/lib/hecks/bench/report.rb +0 -116
- data/lib/hecks/bench/ruby_runner.rb +0 -151
- data/lib/hecks/bench/run.rb +0 -55
- data/lib/hecks/bench/rust_runner.rb +0 -165
- data/lib/hecks/bench/stats.rb +0 -75
- data/lib/hecks/bench/suite.rb +0 -172
- data/lib/hecks/bench/workload.rb +0 -137
- data/lib/hecks/bench.rb +0 -28
- data/lib/hecks/codemod.rb +0 -443
- data/lib/hecks/corpus.rb +0 -479
- data/lib/hecks/doc/reference.rb +0 -585
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -684
- data/lib/hecks/fuzzing/combination_miner.rb +0 -178
- data/lib/hecks/fuzzing/concurrent_dispatch.rb +0 -469
- data/lib/hecks/fuzzing/coverage_campaign.rb +0 -161
- data/lib/hecks/fuzzing/differential.rb +0 -192
- data/lib/hecks/fuzzing/domain_generator.rb +0 -871
- data/lib/hecks/fuzzing/era_boundary.rb +0 -154
- data/lib/hecks/fuzzing/form_census.rb +0 -285
- data/lib/hecks/fuzzing/generated_domain_check.rb +0 -171
- data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -125
- data/lib/hecks/fuzzing/isolated_boot.rb +0 -658
- data/lib/hecks/fuzzing/nondeterministic.rb +0 -79
- data/lib/hecks/fuzzing/persistence_parity.rb +0 -253
- data/lib/hecks/fuzzing/properties/corrections.rb +0 -125
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +0 -688
- data/lib/hecks/fuzzing/properties/guards.rb +0 -398
- data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +0 -399
- data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +0 -137
- data/lib/hecks/fuzzing/properties/outbox.rb +0 -180
- data/lib/hecks/fuzzing/properties/querying.rb +0 -260
- data/lib/hecks/fuzzing/properties.rb +0 -267
- data/lib/hecks/fuzzing/qa_settings.rb +0 -164
- data/lib/hecks/fuzzing/replay.rb +0 -930
- data/lib/hecks/fuzzing/rotation_priority.rb +0 -114
- data/lib/hecks/fuzzing/rust_gap_manifest.rb +0 -139
- data/lib/hecks/fuzzing/self_consistency.rb +0 -825
- data/lib/hecks/fuzzing/sequence_generator/adversary.rb +0 -532
- data/lib/hecks/fuzzing/sequence_generator/catalog.rb +0 -171
- data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +0 -127
- data/lib/hecks/fuzzing/sequence_generator/picker.rb +0 -116
- data/lib/hecks/fuzzing/sequence_generator/query_binding.rb +0 -138
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +0 -344
- data/lib/hecks/fuzzing/sequence_generator.rb +0 -311
- data/lib/hecks/fuzzing/shrinker.rb +0 -309
- data/lib/hecks/fuzzing/structural_skips.rb +0 -53
- data/lib/hecks/fuzzing/sweep_depth.rb +0 -61
- data/lib/hecks/fuzzing/target_capabilities.rb +0 -255
- data/lib/hecks/fuzzing/value_generator.rb +0 -340
- data/lib/hecks/fuzzing.rb +0 -23
- data/lib/hecks/grammar/evolve.rb +0 -524
- 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
|
|
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
|
|
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
|
|
37
|
+
def call(bluebook, hecksagon: nil)
|
|
50
38
|
bluebook.read_models.flat_map do |model|
|
|
51
|
-
|
|
52
|
-
end
|
|
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
|
|
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
|
|
48
|
-
#
|
|
49
|
-
# cannot occur
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
data/lib/hecks/cli/ir.rb
ADDED
|
@@ -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
|