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
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
require_relative "../../hecks"
|
|
2
|
+
require_relative "../bluebook/smoke_test"
|
|
3
|
+
|
|
4
|
+
module Hecks
|
|
5
|
+
module CLI
|
|
6
|
+
# The command behind `bin/smoke_test` and `hecks smoke_test`: boots a real domain
|
|
7
|
+
# and dispatches one synthesized call per declared command and report
|
|
8
|
+
# (`Bluebook::SmokeTest`), printing every failure rather than the first.
|
|
9
|
+
#
|
|
10
|
+
# It needs a real `.hecksagon`: a bare `.bluebook` with nothing bound to an
|
|
11
|
+
# adapter has nothing to run commands against. A failure may be a domain bug or
|
|
12
|
+
# a value the synthesizer's naive choices (0, an empty list, "smoke-test") cannot
|
|
13
|
+
# satisfy; the message underneath says which. Smoke-testing proves nothing
|
|
14
|
+
# crashes, not that an answer is right.
|
|
15
|
+
module SmokeTest
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Smoke-tests the domain `argv` names, or every wired domain under
|
|
19
|
+
# `root/examples/`, and exits with the verdict.
|
|
20
|
+
#
|
|
21
|
+
# @param argv [Array<String>] an optional domain directory
|
|
22
|
+
# @param root [String] the directory whose `examples/*` are tested when `argv`
|
|
23
|
+
# names no domain
|
|
24
|
+
# @return [void]
|
|
25
|
+
# @raise [SystemExit] always: 0 when clean, 1 with failures or no domain found
|
|
26
|
+
def call(argv, root:)
|
|
27
|
+
dir_arg = argv.first
|
|
28
|
+
targets = dir_arg ? [dir_arg] : Dir.glob("#{root}/examples/*/").map { |d| d.chomp("/") }.select { |d| wired?(d) }
|
|
29
|
+
abort "no wired example domains found (none of examples/* has a .hecksagon)" if targets.empty?
|
|
30
|
+
|
|
31
|
+
ok = targets.reduce(true) { |all_ok, dir| clean?(dir) && all_ok }
|
|
32
|
+
|
|
33
|
+
puts
|
|
34
|
+
puts ok ? "Every declared command and report dispatched cleanly." : "SMOKE TEST FOUND PROBLEMS."
|
|
35
|
+
exit(ok ? 0 : 1)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Says whether `dir`, or something nested under it, is a bindable domain.
|
|
39
|
+
#
|
|
40
|
+
# @param dir [String] a directory to check
|
|
41
|
+
# @return [Boolean] true if `dir` contains at least one `.hecksagon` file
|
|
42
|
+
def wired?(dir) = !Dir.glob(File.join(dir, "**/*.hecksagon")).empty?
|
|
43
|
+
|
|
44
|
+
# @api private
|
|
45
|
+
def clean?(dir)
|
|
46
|
+
puts "── #{File.basename(dir)}"
|
|
47
|
+
failures = Bluebook::SmokeTest.call(dir)
|
|
48
|
+
if failures.empty?
|
|
49
|
+
puts " clean — every declared command and report dispatched cleanly"
|
|
50
|
+
return true
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
puts " #{failures.size} failure(s)"
|
|
54
|
+
failures.each { |f| puts " #{f}" }
|
|
55
|
+
false
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require_relative "../../hecks"
|
|
3
|
+
|
|
4
|
+
module Hecks
|
|
5
|
+
module CLI
|
|
6
|
+
# The command behind `bin/stores` and `hecks stores`: every aggregate's current
|
|
7
|
+
# records as JSON, the head rather than the journal (`bin/history` prints the
|
|
8
|
+
# full write history).
|
|
9
|
+
module Stores
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Boots the domain `argv` names and prints its stores as one JSON document.
|
|
13
|
+
#
|
|
14
|
+
# @param argv [Array<String>] the domain directory, first
|
|
15
|
+
# @param program [String] the name the error message calls this command by
|
|
16
|
+
# @return [void]
|
|
17
|
+
# @raise [IndexError] when `argv` is empty
|
|
18
|
+
# @raise [SystemExit] when the domain directory does not exist
|
|
19
|
+
def call(argv, program:)
|
|
20
|
+
domain = argv.fetch(0)
|
|
21
|
+
unless Dir.exist?(domain)
|
|
22
|
+
warn "#{program}: no such domain #{domain.inspect}"
|
|
23
|
+
exit 1
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
registry = Hecks.boot(domain).registry
|
|
27
|
+
stores = registry.bluebooks.each_with_object({}) do |(name, bluebook), all|
|
|
28
|
+
bluebook.aggregates.each do |aggregate|
|
|
29
|
+
all[aggregate.storage_name] = data_for(registry.repository(name, aggregate))
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
puts JSON.generate(stores)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Reads one aggregate's current records as plain JSON-ready hashes, ordered by id.
|
|
37
|
+
#
|
|
38
|
+
# @param repository [Ports::Persistence::AppendOnly] the aggregate's repository
|
|
39
|
+
# @return [Array<Hash{String => Object}>] one hash per stored `Runtime::Instance`,
|
|
40
|
+
# sorted by id; `[]` when the aggregate has no records
|
|
41
|
+
def records(repository)
|
|
42
|
+
repository.all.sort_by(&:id).map { |instance| JSON.parse(JSON.generate(instance.to_h)) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Shapes one aggregate's dump for the top-level stores document.
|
|
46
|
+
#
|
|
47
|
+
# @param repository [Ports::Persistence::AppendOnly] the aggregate's repository
|
|
48
|
+
# @return [Hash{String => Object}] `"authoritative"` mapped to `records(repository)`
|
|
49
|
+
def data_for(repository)
|
|
50
|
+
{ "authoritative" => records(repository) }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
data/lib/hecks/cli.rb
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
module Hecks
|
|
2
|
+
# The `hecks` command an installed gem puts on the path: one router over the
|
|
3
|
+
# domain-operator tools, each of which also runs from a checkout as `bin/<name>`.
|
|
4
|
+
#
|
|
5
|
+
# ## A router, not a second copy
|
|
6
|
+
#
|
|
7
|
+
# Every subcommand's logic lives in `lib/hecks/cli/`, and the matching `bin/`
|
|
8
|
+
# script is a thin wrapper over the same entry point, so `hecks run` and
|
|
9
|
+
# `bin/run` cannot drift apart. The domain-scoped verbs go through
|
|
10
|
+
# `Facade::CliRunner`, the same way `bin/run` does.
|
|
11
|
+
#
|
|
12
|
+
# ## What a checkout has and an install does not
|
|
13
|
+
#
|
|
14
|
+
# The `bin/` wrappers pass the repository root, which is where they look for
|
|
15
|
+
# `examples/`, `rust/` and `docs/generated/`. Here the root is the working
|
|
16
|
+
# directory, so a command that lists or writes files does it where the caller
|
|
17
|
+
# stands. `model_check` with no domain sweeps the repository's corpus, which an
|
|
18
|
+
# install does not carry, so here it needs at least one domain path.
|
|
19
|
+
#
|
|
20
|
+
# ## Loading
|
|
21
|
+
#
|
|
22
|
+
# Nothing here requires the framework. A subcommand loads it when it runs, and
|
|
23
|
+
# `mcp` checks its transport before anything else loads, as
|
|
24
|
+
# `McpStdioGuard.enforce_stdio!` requires.
|
|
25
|
+
#
|
|
26
|
+
# See `docs/decisions/0066-the-gem-ships-a-hecks-executable-and-dev-tooling-stays-in-the-repo.md`.
|
|
27
|
+
module CLI
|
|
28
|
+
# One subcommand's help: a one-line summary and its usage line.
|
|
29
|
+
Command = Struct.new(:summary, :usage)
|
|
30
|
+
|
|
31
|
+
# Every subcommand, in the order the help lists them.
|
|
32
|
+
COMMANDS = {
|
|
33
|
+
"run" => Command.new(
|
|
34
|
+
"Dispatch a verb, or run a JSON step list, against a domain.",
|
|
35
|
+
"hecks run [domain] <verb [name=value …] | script.json | - | '{\"steps\":[…]}'>"
|
|
36
|
+
),
|
|
37
|
+
"docs" => Command.new(
|
|
38
|
+
"Print a domain's usage document, projected from its bluebook.",
|
|
39
|
+
"hecks docs [domain-path] [aggregate]"
|
|
40
|
+
),
|
|
41
|
+
"narrate" => Command.new(
|
|
42
|
+
"Print a domain read back in English, projected from its bluebook.",
|
|
43
|
+
"hecks narrate [domain-path] [aggregate]"
|
|
44
|
+
),
|
|
45
|
+
"ir" => Command.new(
|
|
46
|
+
"Print a booted domain's IR as JSON.",
|
|
47
|
+
"hecks ir <domain> [--translations] | hecks ir --meta"
|
|
48
|
+
),
|
|
49
|
+
"stores" => Command.new(
|
|
50
|
+
"Print every aggregate's current records as JSON.",
|
|
51
|
+
"hecks stores <domain>"
|
|
52
|
+
),
|
|
53
|
+
"model_check" => Command.new(
|
|
54
|
+
"Statically check a domain's IR for dead states and unreachable steps.",
|
|
55
|
+
"hecks model_check [--strict] [--profile client] <domain> [<domain> …]"
|
|
56
|
+
),
|
|
57
|
+
"smoke_test" => Command.new(
|
|
58
|
+
"Boot a domain and dispatch every declared command and report once.",
|
|
59
|
+
"hecks smoke_test [domain]"
|
|
60
|
+
),
|
|
61
|
+
"project_diagrams" => Command.new(
|
|
62
|
+
"Write a domain's Mermaid diagrams under ./docs/generated/diagrams/.",
|
|
63
|
+
"hecks project_diagrams <domain-path> <ChapterName>"
|
|
64
|
+
),
|
|
65
|
+
"project_cli" => Command.new(
|
|
66
|
+
"Write a command-line launcher beside each domain, named after its bluebook.",
|
|
67
|
+
"hecks project_cli [domain-path …]"
|
|
68
|
+
),
|
|
69
|
+
"mcp" => Command.new(
|
|
70
|
+
"Serve the MCP door over stdio (no authentication; stdio only).",
|
|
71
|
+
"hecks mcp [--stdio]"
|
|
72
|
+
)
|
|
73
|
+
}.freeze
|
|
74
|
+
|
|
75
|
+
HELP_FLAGS = ["--help", "-h"].freeze
|
|
76
|
+
|
|
77
|
+
# Exit status for a missing or unknown subcommand.
|
|
78
|
+
USAGE_STATUS = 2
|
|
79
|
+
|
|
80
|
+
module_function
|
|
81
|
+
|
|
82
|
+
# Routes `argv` to its subcommand and runs it.
|
|
83
|
+
#
|
|
84
|
+
# A subcommand followed by only `--help` or `-h` prints that subcommand's usage
|
|
85
|
+
# without running it; any other arguments pass through untouched, so
|
|
86
|
+
# `hecks run order.place --help` still reaches the verb's own help.
|
|
87
|
+
#
|
|
88
|
+
# @param argv [Array<String>] the command line, subcommand first
|
|
89
|
+
# @param out [#puts] where help goes
|
|
90
|
+
# @param err [#puts] where usage errors go
|
|
91
|
+
# @return [Integer] the process exit status for a subcommand that returns; a
|
|
92
|
+
# subcommand that fails exits the process itself
|
|
93
|
+
def start(argv, out: $stdout, err: $stderr)
|
|
94
|
+
name, *rest = argv
|
|
95
|
+
if name.nil? || !COMMANDS.key?(name)
|
|
96
|
+
asked = HELP_FLAGS.include?(name) || name == "help"
|
|
97
|
+
err.puts "hecks: unknown command #{name.inspect}" unless asked || name.nil?
|
|
98
|
+
overview(asked ? out : err)
|
|
99
|
+
return asked ? 0 : USAGE_STATUS
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
if rest.length == 1 && HELP_FLAGS.include?(rest.first)
|
|
103
|
+
help(COMMANDS.fetch(name), out)
|
|
104
|
+
else
|
|
105
|
+
dispatch(name, rest)
|
|
106
|
+
end
|
|
107
|
+
0
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Runs one known subcommand with the working directory as its root.
|
|
111
|
+
#
|
|
112
|
+
# @param name [String] a key of `COMMANDS`
|
|
113
|
+
# @param argv [Array<String>] the arguments after the subcommand
|
|
114
|
+
# @return [void]
|
|
115
|
+
# @raise [SystemExit] when the subcommand exits the process itself
|
|
116
|
+
def dispatch(name, argv)
|
|
117
|
+
program = "hecks #{name}"
|
|
118
|
+
case name
|
|
119
|
+
when "run"
|
|
120
|
+
require_relative "cli/run"
|
|
121
|
+
Run.call(argv, program: program)
|
|
122
|
+
when "docs", "narrate"
|
|
123
|
+
require_relative "cli/document"
|
|
124
|
+
Document.call(argv, projection: name.to_sym, program: program, root: Dir.pwd)
|
|
125
|
+
when "ir"
|
|
126
|
+
require_relative "cli/ir"
|
|
127
|
+
Ir.call(argv, program: program)
|
|
128
|
+
when "stores"
|
|
129
|
+
require_relative "cli/stores"
|
|
130
|
+
Stores.call(argv, program: program)
|
|
131
|
+
when "model_check"
|
|
132
|
+
require_relative "cli/model_check"
|
|
133
|
+
ModelCheck.call(argv, program: program)
|
|
134
|
+
when "smoke_test"
|
|
135
|
+
require_relative "cli/smoke_test"
|
|
136
|
+
SmokeTest.call(argv, root: Dir.pwd)
|
|
137
|
+
when "project_diagrams"
|
|
138
|
+
require_relative "cli/project_diagrams"
|
|
139
|
+
ProjectDiagrams.call(argv, program: program, root: Dir.pwd)
|
|
140
|
+
when "project_cli"
|
|
141
|
+
require_relative "cli/project_cli"
|
|
142
|
+
ProjectCli.call(argv, program: program, root: Dir.pwd, remove_stale_bin: false)
|
|
143
|
+
when "mcp"
|
|
144
|
+
require_relative "cli/mcp"
|
|
145
|
+
Mcp.call(argv)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# @api private
|
|
150
|
+
def overview(io)
|
|
151
|
+
io.puts "usage: hecks <command> [arguments]"
|
|
152
|
+
io.puts ""
|
|
153
|
+
width = COMMANDS.keys.map(&:length).max
|
|
154
|
+
COMMANDS.each { |name, command| io.puts " #{name.ljust(width)} #{command.summary}" }
|
|
155
|
+
io.puts ""
|
|
156
|
+
io.puts "Run `hecks <command> --help` for one command's usage."
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# @api private
|
|
160
|
+
def help(command, io)
|
|
161
|
+
io.puts "usage: #{command.usage}"
|
|
162
|
+
io.puts ""
|
|
163
|
+
io.puts command.summary
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
data/lib/hecks/facade/handle.rb
CHANGED
|
@@ -142,14 +142,14 @@ module Hecks
|
|
|
142
142
|
# privacy.bluebook): a marking's presence is what makes a read
|
|
143
143
|
# redacted, not a separate flag this class carries itself.
|
|
144
144
|
#
|
|
145
|
-
#
|
|
145
|
+
# Always the strong check, never the weak string-only fallback
|
|
146
146
|
# `CommandRules::Authorization#refuse_role_mismatch` allows an
|
|
147
147
|
# unidentified caller — a read gate gone wrong is a leak, not a
|
|
148
148
|
# refused command, so an ambient caller with no `actor_id` (or no
|
|
149
149
|
# caller at all) is masked here, full stop, rather than waved
|
|
150
150
|
# through the way a self-asserted `role` string is for a command.
|
|
151
151
|
#
|
|
152
|
-
#
|
|
152
|
+
# One level of nesting only — `attendee.medications` masks inside
|
|
153
153
|
# the returned `Runtime::Value` via its own `#with`; a marking two
|
|
154
154
|
# levels deep is not supported and is left unmasked rather than
|
|
155
155
|
# silently mishandled, since nothing in this corpus needs it yet.
|
|
@@ -165,7 +165,7 @@ module Hecks
|
|
|
165
165
|
end
|
|
166
166
|
|
|
167
167
|
door.define_singleton_method(:method_missing) do |verb, *args, **kwargs, &block|
|
|
168
|
-
# A
|
|
168
|
+
# A bare call starts a Privacy marking chain — see
|
|
169
169
|
# `Bluebook::DSL::BindingProxy#method_missing`'s own header;
|
|
170
170
|
# this is the same mechanism, reached when the constant is
|
|
171
171
|
# already a real, installed door (a second boot in-process)
|
|
@@ -339,6 +339,12 @@ Hecks.bluebook "Bluebook" do
|
|
|
339
339
|
member refusal: "InvariantViolation", site: "undeclared_set",
|
|
340
340
|
template: "{name} admits {admits}, which this chapter does not declare — a closed set is named " \
|
|
341
341
|
"Aggregate::SetName, and it must be one the bluebook actually holds"
|
|
342
|
+
# ADR 0061 decision D1: a group_by leaf holds one row, so two rows
|
|
343
|
+
# reaching the same full key path refuse at dispatch rather than one
|
|
344
|
+
# of them being dropped.
|
|
345
|
+
member refusal: "InvariantViolation", site: "group_by_collision",
|
|
346
|
+
template: "{read_model} groups by {fields}, but rows {ids} share {key} — a group_by " \
|
|
347
|
+
"leaf holds one row; add a field that tells them apart"
|
|
342
348
|
member refusal: "Unauthorized", site: "tenant_required",
|
|
343
349
|
template: "{query} declares authorize with tenant: {field} — pass {field}: to name which " \
|
|
344
350
|
"{field} this ask is scoped to"
|
|
@@ -620,6 +626,16 @@ Hecks.bluebook "Bluebook" do
|
|
|
620
626
|
shape: "scalar", quoting: "none", separator: "", sorted: "false", when_empty: ""
|
|
621
627
|
member refusal: "InvariantViolation", site: "undeclared_set", argument: "admits",
|
|
622
628
|
shape: "scalar", quoting: "none", separator: "", sorted: "false", when_empty: ""
|
|
629
|
+
member refusal: "InvariantViolation", site: "group_by_collision", argument: "read_model",
|
|
630
|
+
shape: "scalar", quoting: "none", separator: "", sorted: "false", when_empty: ""
|
|
631
|
+
member refusal: "InvariantViolation", site: "group_by_collision", argument: "fields",
|
|
632
|
+
shape: "list", quoting: "none", separator: ", ", sorted: "false", when_empty: ""
|
|
633
|
+
member refusal: "InvariantViolation", site: "group_by_collision", argument: "ids",
|
|
634
|
+
shape: "list", quoting: "inspect", separator: ", ", sorted: "true", when_empty: ""
|
|
635
|
+
# "field = value" per key-path level, joined with ", " by the caller:
|
|
636
|
+
# each value is its own `to_s`, the same text the nested Hash keys it by.
|
|
637
|
+
member refusal: "InvariantViolation", site: "group_by_collision", argument: "key",
|
|
638
|
+
shape: "scalar", quoting: "none", separator: "", sorted: "false", when_empty: ""
|
|
623
639
|
member refusal: "Unauthorized", site: "tenant_required", argument: "query",
|
|
624
640
|
shape: "scalar", quoting: "none", separator: "", sorted: "false", when_empty: ""
|
|
625
641
|
member refusal: "Unauthorized", site: "tenant_required", argument: "field",
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
require_relative "runtime/errors"
|
|
2
|
+
|
|
3
|
+
module Hecks
|
|
4
|
+
# What one spawned `bin/hecks_mcp_door` may do: every tool against any domain under
|
|
5
|
+
# `Storehouse::BOOT_ROOT` (the default), or, in reader mode, only the reader tools
|
|
6
|
+
# against the domains its spawner named. ADR 0072, decision 2.
|
|
7
|
+
#
|
|
8
|
+
# ## Reach, not identity
|
|
9
|
+
#
|
|
10
|
+
# This is a capability allowlist, not authentication. It identifies no one and adds no
|
|
11
|
+
# secret: whoever can spawn the door chooses its mode, and whoever can write to its stdin
|
|
12
|
+
# gets whatever that mode allows. What it buys is a smaller surface per agent. A reader
|
|
13
|
+
# door cannot dispatch, cannot run `.behaviors` files, and cannot `Kernel.load` Ruby from
|
|
14
|
+
# a domain its spawner did not name.
|
|
15
|
+
#
|
|
16
|
+
# ## Why `HECKS_DOOR_*` and not `HECKS_MCP_*`
|
|
17
|
+
#
|
|
18
|
+
# `McpStdioGuard` refuses every `HECKS_MCP_*` variable except
|
|
19
|
+
# `HECKS_MCP_TRANSPORT=stdio`, and every argument except `--stdio`, so that no option can
|
|
20
|
+
# ask a server for another transport. Those refusals belong to ADR 0062. The settings
|
|
21
|
+
# here live under their own prefix so the guard stays exactly as that ADR describes it,
|
|
22
|
+
# and an unknown `HECKS_DOOR_*` name is refused here, the same fail-closed way.
|
|
23
|
+
#
|
|
24
|
+
# ## The settings
|
|
25
|
+
#
|
|
26
|
+
# - `HECKS_DOOR_TOOLS=readers` turns reader mode on. Unset means every tool, as before.
|
|
27
|
+
# Any other value refuses to start.
|
|
28
|
+
# - `HECKS_DOOR_DOMAINS` lists the domains a reader door may name, separated by
|
|
29
|
+
# `File::PATH_SEPARATOR` (`:` on Unix), each resolved against `Storehouse::BOOT_ROOT`.
|
|
30
|
+
# Required in reader mode and refused without it.
|
|
31
|
+
#
|
|
32
|
+
# ## The allowed set is named, not loaded
|
|
33
|
+
#
|
|
34
|
+
# The door boots a domain afresh on every call and keeps nothing loaded between calls,
|
|
35
|
+
# so there is no set of already-loaded domains to check against. The allowed set is the
|
|
36
|
+
# list the spawner named, resolved to absolute paths at startup. A call's `domain:` is
|
|
37
|
+
# resolved the same way and compared before anything boots, so a path outside the set
|
|
38
|
+
# never reaches `Kernel.load`. The comparison is on resolved path text: a symlink under a
|
|
39
|
+
# named path is followed when that domain boots, as it is for every domain today.
|
|
40
|
+
class McpDoorScope
|
|
41
|
+
ENV_PREFIX = "HECKS_DOOR_".freeze
|
|
42
|
+
TOOLS_VARIABLE = "HECKS_DOOR_TOOLS".freeze
|
|
43
|
+
DOMAINS_VARIABLE = "HECKS_DOOR_DOMAINS".freeze
|
|
44
|
+
READERS = "readers".freeze
|
|
45
|
+
EXIT_STATUS = 2
|
|
46
|
+
|
|
47
|
+
# The tools a reader door serves. Each reads a domain or the door's own audit log and
|
|
48
|
+
# writes no domain state. `query` and `state` still append their call to that audit
|
|
49
|
+
# log. `validate` boots the domain it is given, so it is held to the allowed set like
|
|
50
|
+
# every other domain-scoped tool. `domains` lists directories and loads nothing.
|
|
51
|
+
READER_TOOLS = %w[query events state catalog describe validate domains history follow].freeze
|
|
52
|
+
|
|
53
|
+
# Reads the door's mode from its environment.
|
|
54
|
+
#
|
|
55
|
+
# @param env [Hash{String => String}] the process environment
|
|
56
|
+
# @return [McpDoorScope] an unrestricted scope when no `HECKS_DOOR_*` variable is set;
|
|
57
|
+
# otherwise a reader scope over the named domains
|
|
58
|
+
# @raise [ArgumentError] with every problem found, one per line, when the settings are
|
|
59
|
+
# not a mode this door knows
|
|
60
|
+
# @raise [Runtime::TypeMismatch] if a named domain resolves outside
|
|
61
|
+
# `Storehouse::BOOT_ROOT`
|
|
62
|
+
def self.from_env(env = ENV)
|
|
63
|
+
problems = setting_problems(env)
|
|
64
|
+
raise ArgumentError, problems.join("\n") unless problems.empty?
|
|
65
|
+
return new(allowed_domains: nil) unless env[TOOLS_VARIABLE]
|
|
66
|
+
|
|
67
|
+
new(allowed_domains: env[DOMAINS_VARIABLE].split(File::PATH_SEPARATOR).reject(&:empty?))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Reads the door's mode, or refuses to start on settings it does not know.
|
|
71
|
+
#
|
|
72
|
+
# Everything goes to `stderr`, because stdout carries the MCP protocol.
|
|
73
|
+
#
|
|
74
|
+
# @param server [String] the server's name, used as the line prefix
|
|
75
|
+
# @param env [Hash{String => String}] the process environment
|
|
76
|
+
# @param stderr [IO] where a refusal is written
|
|
77
|
+
# @return [McpDoorScope] the door's scope
|
|
78
|
+
# @raise [SystemExit] with status `EXIT_STATUS` when `from_env` refuses the settings
|
|
79
|
+
def self.start!(server:, env: ENV, stderr: $stderr)
|
|
80
|
+
from_env(env)
|
|
81
|
+
rescue ArgumentError, Runtime::TypeMismatch => e
|
|
82
|
+
e.message.each_line { |line| stderr.puts("#{server}: refusing to start: #{line.chomp}") }
|
|
83
|
+
exit(EXIT_STATUS)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @api private
|
|
87
|
+
def self.setting_problems(env)
|
|
88
|
+
unknown = env.keys.select { |name| name.start_with?(ENV_PREFIX) } - [TOOLS_VARIABLE, DOMAINS_VARIABLE]
|
|
89
|
+
problems = unknown.map do |name|
|
|
90
|
+
"environment #{name} is not accepted; the door takes only #{TOOLS_VARIABLE} and #{DOMAINS_VARIABLE}"
|
|
91
|
+
end
|
|
92
|
+
problems + mode_problems(env[TOOLS_VARIABLE], env[DOMAINS_VARIABLE])
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @api private
|
|
96
|
+
def self.mode_problems(tools, domains)
|
|
97
|
+
if tools.nil?
|
|
98
|
+
domains.nil? ? [] : ["#{DOMAINS_VARIABLE} is set without #{TOOLS_VARIABLE}=#{READERS}; it only narrows a reader door"]
|
|
99
|
+
elsif tools != READERS
|
|
100
|
+
["#{TOOLS_VARIABLE}=#{tools.inspect} is not accepted; the only value is #{READERS.inspect}"]
|
|
101
|
+
elsif domains.to_s.split(File::PATH_SEPARATOR).none? { |path| !path.empty? }
|
|
102
|
+
["#{TOOLS_VARIABLE}=#{READERS} needs #{DOMAINS_VARIABLE}, the domains this door may read"]
|
|
103
|
+
else
|
|
104
|
+
[]
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The named domains, resolved against `Storehouse::BOOT_ROOT`; nil when unrestricted.
|
|
109
|
+
#
|
|
110
|
+
# @return [Array<String>, nil]
|
|
111
|
+
attr_reader :allowed_domains
|
|
112
|
+
|
|
113
|
+
# @param allowed_domains [Array<String>, nil] the domains a reader door may name, as
|
|
114
|
+
# given; nil for an unrestricted door
|
|
115
|
+
# @raise [Runtime::TypeMismatch] if a named domain resolves outside `Storehouse::BOOT_ROOT`
|
|
116
|
+
def initialize(allowed_domains:)
|
|
117
|
+
@allowed_domains = allowed_domains&.map { |path| Storehouse.confine!(path, DOMAINS_VARIABLE) }.freeze
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Tells whether this door runs in reader mode.
|
|
121
|
+
#
|
|
122
|
+
# @return [Boolean] true when the spawner set `HECKS_DOOR_TOOLS=readers`
|
|
123
|
+
def restricted?
|
|
124
|
+
!@allowed_domains.nil?
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Tells whether this door serves a tool.
|
|
128
|
+
#
|
|
129
|
+
# @param name [String] the tool name from a `tools/call` request
|
|
130
|
+
# @return [Boolean] true for every name on an unrestricted door; on a reader door, true
|
|
131
|
+
# only for `READER_TOOLS`
|
|
132
|
+
def permits_tool?(name)
|
|
133
|
+
!restricted? || READER_TOOLS.include?(name)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Builds the answer to a call for a tool this door does not serve.
|
|
137
|
+
#
|
|
138
|
+
# @param name [String] the tool name from a `tools/call` request
|
|
139
|
+
# @return [Hash{Symbol => Object}] `{ok: false, error:}`, the error naming the mode and
|
|
140
|
+
# the tools it serves
|
|
141
|
+
def refusal(name)
|
|
142
|
+
{ ok: false,
|
|
143
|
+
error: "#{name.inspect} is refused: this door runs in reader mode (#{TOOLS_VARIABLE}=#{READERS}), " \
|
|
144
|
+
"which serves only #{READER_TOOLS.join(', ')}" }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Checks a call's `domain:` against the allowed set, before anything boots.
|
|
148
|
+
#
|
|
149
|
+
# @param domain [String, nil] the caller's `domain:` argument
|
|
150
|
+
# @return [String, nil] `domain` unchanged, so an unrestricted door boots exactly what
|
|
151
|
+
# it did before
|
|
152
|
+
# @raise [Runtime::TypeMismatch] on a reader door, if `domain` resolves outside
|
|
153
|
+
# `Storehouse::BOOT_ROOT` or to a path its spawner did not name
|
|
154
|
+
def admit_domain!(domain)
|
|
155
|
+
return domain unless restricted?
|
|
156
|
+
return domain if @allowed_domains.include?(Storehouse.confine!(domain, "domain"))
|
|
157
|
+
|
|
158
|
+
raise Runtime::TypeMismatch,
|
|
159
|
+
"domain: #{domain.inspect} is refused: this door runs in reader mode (#{TOOLS_VARIABLE}=#{READERS}) " \
|
|
160
|
+
"and reads only #{DOMAINS_VARIABLE}: #{@allowed_domains.join(', ')}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# The startup warning lines that describe this door's mode.
|
|
164
|
+
#
|
|
165
|
+
# @return [Array<String>] empty when unrestricted; on a reader door, what it serves,
|
|
166
|
+
# what it refuses, the domains it reads, and that it identifies no one
|
|
167
|
+
def notes
|
|
168
|
+
return [] unless restricted?
|
|
169
|
+
|
|
170
|
+
["Reader mode (#{TOOLS_VARIABLE}=#{READERS}): serves #{READER_TOOLS.join(', ')}; " \
|
|
171
|
+
"refuses dispatch (with dry_run and steps), behaviors and every other tool.",
|
|
172
|
+
"domain: boots only #{DOMAINS_VARIABLE}: #{@allowed_domains.join(', ')}.",
|
|
173
|
+
"Reader mode limits reach and identifies no one; it is not authentication."]
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -308,7 +308,7 @@ module Hecks
|
|
|
308
308
|
#
|
|
309
309
|
# @param id [Integer] the row id assigned by `outbox_enqueue`
|
|
310
310
|
# @return [Boolean] true when this call claimed the row; false when it is missing or
|
|
311
|
-
#
|
|
311
|
+
# not pending
|
|
312
312
|
def outbox_claim(id) = @adapter.outbox_claim(id)
|
|
313
313
|
|
|
314
314
|
# Records the final status of a claimed outbox row.
|
|
@@ -123,7 +123,7 @@ module Hecks
|
|
|
123
123
|
#
|
|
124
124
|
# The domain's own directory first, matched by name — a real app's
|
|
125
125
|
# directory may hold more than one file once `uses_framework`
|
|
126
|
-
# exists, so ".first" alone
|
|
126
|
+
# exists, so ".first" alone cannot be trusted, the exact
|
|
127
127
|
# way it silently wasn't the day this was found: three domains
|
|
128
128
|
# booted together, one real source text read once (the domain's
|
|
129
129
|
# own, ".first"'d), and every other domain's era-1 held that text
|
|
@@ -131,7 +131,7 @@ module Hecks
|
|
|
131
131
|
# Normal parse first, shadow only as a fallback — never shadow-parsing
|
|
132
132
|
# unconditionally. A handful of DSL
|
|
133
133
|
# defaults fork on `MetaValidator.shadow_parsing?` for a reason
|
|
134
|
-
# that has nothing to do with syntax the live grammar
|
|
134
|
+
# that has nothing to do with syntax the live grammar cannot
|
|
135
135
|
# read at all (`identified_by { }`, `belongs_to`, `has_one`,
|
|
136
136
|
# `has_many` — genuinely removed spellings, exactly what shadow-
|
|
137
137
|
# parsing exists to keep readable): `reference_to`'s own default
|
|
@@ -158,7 +158,7 @@ module Hecks
|
|
|
158
158
|
# interpretation, the same one `label_of`/`mint_hash` on the same
|
|
159
159
|
# source text always computes, whoever's asking. Only on a
|
|
160
160
|
# `Malformed` refusal — the one signal that actually means "this
|
|
161
|
-
# spelling
|
|
161
|
+
# spelling is not in the live grammar" — fall back to the legacy
|
|
162
162
|
# grammar. Any other exception (a
|
|
163
163
|
# genuine syntax error, an unrelated validation refusal) propagates
|
|
164
164
|
# unchanged; swallowing it here to retry under shadow mode would
|
|
@@ -5,7 +5,7 @@ require_relative "storage_shape"
|
|
|
5
5
|
|
|
6
6
|
module Hecks
|
|
7
7
|
module Runtime
|
|
8
|
-
# The refusal wording for a held era whose digest
|
|
8
|
+
# The refusal wording for a held era whose digest does not match its
|
|
9
9
|
# frozen text — the digest mismatch alone is what detects tampering (a
|
|
10
10
|
# plain SHA256 comparison over raw bytes, unrelated to any of this);
|
|
11
11
|
# this only supplies the wording once that's already fired.
|
|
@@ -28,7 +28,7 @@ module Hecks
|
|
|
28
28
|
module EraTamper
|
|
29
29
|
module_function
|
|
30
30
|
|
|
31
|
-
# Words the boot refusal for a held era text whose digest
|
|
31
|
+
# Words the boot refusal for a held era text whose digest does not match.
|
|
32
32
|
#
|
|
33
33
|
# @param domain [String] name of the domain whose era was edited
|
|
34
34
|
# @param ordinal [Integer] the edited era's ordinal in `hecks_eras`
|
|
@@ -24,7 +24,7 @@ module Hecks
|
|
|
24
24
|
# the domain holds none; keys are `:ordinal` (Integer), `:hash` and `:label` (String,
|
|
25
25
|
# nil until `mint_name!` names the era), `:held_text` (String) and `:watermark`
|
|
26
26
|
# (Integer journal ordinal the era was cut at, nil for era 1)
|
|
27
|
-
# @raise [Runtime::WiringError] if a held text
|
|
27
|
+
# @raise [Runtime::WiringError] if a held text does not match its stored digest
|
|
28
28
|
def eras
|
|
29
29
|
@db.exec_params(
|
|
30
30
|
"SELECT ordinal, hash, label, held_text, watermark, held_digest, held_projection::text " \
|
|
@@ -153,7 +153,7 @@ module Hecks
|
|
|
153
153
|
# Reports the ordinal of the newest era the domain holds.
|
|
154
154
|
#
|
|
155
155
|
# @return [Integer] the highest held ordinal, or 1 when no era is held yet
|
|
156
|
-
# @raise [Runtime::WiringError] if a held text
|
|
156
|
+
# @raise [Runtime::WiringError] if a held text does not match its stored digest
|
|
157
157
|
def current_era
|
|
158
158
|
held = eras
|
|
159
159
|
held.empty? ? 1 : held.last[:ordinal]
|
|
@@ -553,7 +553,7 @@ module Hecks
|
|
|
553
553
|
# `full:` forces a rebuild from the raw journal. The tail-merge
|
|
554
554
|
# needs it: it moves every watermark to the new tip, so every
|
|
555
555
|
# ancestor matview's cut goes stale in the same statement, and
|
|
556
|
-
# layering on one would carry a cut that
|
|
556
|
+
# layering on one would carry a cut that does not exist.
|
|
557
557
|
#
|
|
558
558
|
# The live half reads the snapshot table, not `WHERE era = era AND
|
|
559
559
|
# aggregate = name` over the raw journal — that would be a
|
|
@@ -107,7 +107,7 @@ module Hecks
|
|
|
107
107
|
# --approve` and bound to what was actually reviewed: this
|
|
108
108
|
# edge's parsed content, and the journal as it stood when the
|
|
109
109
|
# samples were read. A journal that has advanced past the
|
|
110
|
-
# review invalidates it — the approved samples
|
|
110
|
+
# review invalidates it — the approved samples do not cover
|
|
111
111
|
# the data.
|
|
112
112
|
#
|
|
113
113
|
# @param bluebook [Bluebook::Chapter] the domain, named in the refusal
|
|
@@ -120,23 +120,29 @@ module Hecks
|
|
|
120
120
|
|
|
121
121
|
# A compute is the one rule whose SQL is its only implementation
|
|
122
122
|
# — evaluated exclusively inside the compiled head, never
|
|
123
|
-
# in-process. The
|
|
124
|
-
#
|
|
123
|
+
# in-process. The source is read as a path through
|
|
124
|
+
# `hecks_tr_extract`, bare or a dotted value-object member alike,
|
|
125
|
+
# the same way the destination goes through `path_literal`. The
|
|
126
|
+
# source's value is exposed to the author's SQL as text under a
|
|
127
|
+
# column named by the source exactly as declared (`price_cents`,
|
|
128
|
+
# or `"price.cents"` for a dotted member), and the whole record
|
|
129
|
+
# stays readable as `__s`.
|
|
125
130
|
#
|
|
126
131
|
# @param expression [String] the SQL expression built so far by `compile_rules`, read as
|
|
127
132
|
# `__s` inside the compute's own SQL
|
|
128
133
|
# @param compute [Bluebook::TranslationCompute] the declared compute rule
|
|
129
134
|
# @return [String] `expression` wrapped so the compute's field lands at its declared
|
|
130
|
-
# destination when the source
|
|
135
|
+
# destination when the source path is present, unchanged otherwise
|
|
131
136
|
def compile_compute(expression, compute)
|
|
132
137
|
from = compute.from.to_s
|
|
133
138
|
to = compute.to.to_s
|
|
134
|
-
"(SELECT CASE WHEN
|
|
135
|
-
"hecks_tr_insert(
|
|
139
|
+
"(SELECT CASE WHEN (__x).present THEN " \
|
|
140
|
+
"hecks_tr_insert((__x).remaining, #{path_literal(to)}, to_jsonb((#{compute.sql})), " \
|
|
136
141
|
"#{text_literal("compute #{from} to: #{to}")}) " \
|
|
137
142
|
"ELSE __s END " \
|
|
138
143
|
"FROM (SELECT (#{expression}) AS __s) __outer, " \
|
|
139
|
-
"LATERAL (SELECT (__s
|
|
144
|
+
"LATERAL (SELECT hecks_tr_extract(__s, #{path_literal(from)}) AS __x) __extract, " \
|
|
145
|
+
"LATERAL (SELECT (__s #>> #{path_literal(from)}) AS #{quote(from)}) __fields)"
|
|
140
146
|
end
|
|
141
147
|
|
|
142
148
|
# A newly added, required attribute with no source at all — the
|
|
@@ -44,7 +44,7 @@ module Hecks
|
|
|
44
44
|
# drops a key, nil or not: a stored nil stays a stored nil.
|
|
45
45
|
#
|
|
46
46
|
# It never touches an undeclared key's value — a retired field, or a
|
|
47
|
-
# member a value object
|
|
47
|
+
# member a value object does not declare, is exactly what an Era
|
|
48
48
|
# translation (rename/move/drop) still has to read. Its key keeps its
|
|
49
49
|
# spelling below the top level; at the top level every key is a
|
|
50
50
|
# symbol, declared or not, because every adapter has always
|