hecks 2.5.0 → 2.6.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/lib/hecks/bench/cli.rb +74 -0
- data/lib/hecks/bench/environment.rb +100 -0
- data/lib/hecks/bench/postgres_probe.rb +41 -0
- data/lib/hecks/bench/report.rb +116 -0
- data/lib/hecks/bench/ruby_runner.rb +151 -0
- data/lib/hecks/bench/run.rb +55 -0
- data/lib/hecks/bench/rust_runner.rb +165 -0
- data/lib/hecks/bench/stats.rb +75 -0
- data/lib/hecks/bench/suite.rb +172 -0
- data/lib/hecks/bench/workload.rb +137 -0
- data/lib/hecks/bench.rb +28 -0
- data/lib/hecks/bluebook/model_check/client_profile.rb +153 -0
- data/lib/hecks/bluebook/model_check.rb +20 -1
- data/lib/hecks/fuzzing/isolated_boot.rb +77 -64
- data/lib/hecks/mcp_stdio_guard.rb +160 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks.rb +1 -0
- metadata +15 -2
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
module Hecks
|
|
2
|
+
module Bluebook
|
|
3
|
+
module ModelCheck
|
|
4
|
+
# The opt-in `client` profile: refuses the bluebook constructs this
|
|
5
|
+
# repository already knows produce a silently wrong answer, so a client
|
|
6
|
+
# domain cannot reach one without being told.
|
|
7
|
+
#
|
|
8
|
+
# ## What it is not
|
|
9
|
+
#
|
|
10
|
+
# It fixes nothing. Each rule below names a bug that is still open and
|
|
11
|
+
# stops a domain reaching it unnoticed; when the bug is fixed the rule
|
|
12
|
+
# goes with it. `spec/model_check_client_profile_spec.rb` holds a probe
|
|
13
|
+
# per rule that runs the buggy code and fails the moment the bug stops
|
|
14
|
+
# reproducing, naming the rule to delete.
|
|
15
|
+
#
|
|
16
|
+
# ## The rules
|
|
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
|
+
# - `:client_native_read_model` — a read model the SQLite projection
|
|
22
|
+
# answers natively, where every other path answers it in process
|
|
23
|
+
# (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
|
+
module ClientProfile
|
|
28
|
+
# Adapters whose Ruby class implements `query_read_model`, which is what
|
|
29
|
+
# makes `ReadModelInterpreter#project` take the native path. The spec
|
|
30
|
+
# checks this list against the adapter classes themselves.
|
|
31
|
+
NATIVE_READ_MODEL_ADAPTERS = %w[SqliteProjection].freeze
|
|
32
|
+
|
|
33
|
+
GROUP_BY_TRACKER = "docs/decisions/0061-query-dsl-aggregation-count-sum-group-by.md".freeze
|
|
34
|
+
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
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
# Runs every client-profile rule over one chapter and its translations.
|
|
42
|
+
#
|
|
43
|
+
# @param bluebook [Bluebook::Chapter] the assembled chapter to check
|
|
44
|
+
# @param hecksagon [Bluebook::Hecksagon, nil] the chapter's sibling wiring file; without
|
|
45
|
+
# 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
|
+
# @return [Array<ModelCheck::Finding>] one error-severity finding per construct refused
|
|
49
|
+
def call(bluebook, hecksagon: nil, translations: [])
|
|
50
|
+
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}.")]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Refuses a read model that the SQLite projection would answer natively.
|
|
75
|
+
#
|
|
76
|
+
# Mirrors the fork in `ReadModelInterpreter#project`: a rooted model with no `group_by`,
|
|
77
|
+
# `count` or `median` reads through `read_repository`, which is a projection repository
|
|
78
|
+
# only for an aggregate with a `projected_by` bind. Whether that projection is current
|
|
79
|
+
# is only known at run time (`Registry#projection_current?`), so a model eligible here
|
|
80
|
+
# can also silently run in process.
|
|
81
|
+
#
|
|
82
|
+
# @param model [Bluebook::ReadModel] the read model to inspect
|
|
83
|
+
# @param hecksagon [Bluebook::Hecksagon, nil] the wiring holding the `projected_by` binds
|
|
84
|
+
# @return [Array<ModelCheck::Finding>] one finding, or `[]` when the model cannot be
|
|
85
|
+
# pushed down
|
|
86
|
+
def native_read_model_findings(model, hecksagon)
|
|
87
|
+
return [] unless hecksagon && native_eligible?(model)
|
|
88
|
+
|
|
89
|
+
bind = hecksagon.binds_for(model.reference_target, Ports::Projection::VERB).first
|
|
90
|
+
return [] unless bind && NATIVE_READ_MODEL_ADAPTERS.include?(bind.adapter.to_s)
|
|
91
|
+
|
|
92
|
+
[finding(:client_native_read_model, model.name,
|
|
93
|
+
"#{model.reference_target} is projected_by #{bind.adapter.inspect}, so this read model " \
|
|
94
|
+
"is answered by SQL when the projection is current and by the in-process loop when it " \
|
|
95
|
+
"is not, and nothing checks the two agree. Tracked in #{NATIVE_READ_MODEL_TRACKER}.")]
|
|
96
|
+
end
|
|
97
|
+
|
|
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
|
+
# Reports whether a model's shape sends it down the native path in `#project`.
|
|
119
|
+
#
|
|
120
|
+
# @param model [Bluebook::ReadModel] the read model to inspect
|
|
121
|
+
# @return [Boolean] true for a rooted model declaring no `group_by`, `count` or `median`
|
|
122
|
+
def native_eligible?(model)
|
|
123
|
+
!model.reference_target.nil? && model.group_by.empty? && !model.count? && !model.median_field
|
|
124
|
+
end
|
|
125
|
+
|
|
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
|
+
# Builds one error-severity finding of this profile.
|
|
142
|
+
#
|
|
143
|
+
# @param kind [Symbol] the finding's kind, one of the `:client_*` rules above
|
|
144
|
+
# @param subject [String] the read model or `Domain::Aggregate` the finding is about
|
|
145
|
+
# @param message [String] what was found, why it is refused, and where it is tracked
|
|
146
|
+
# @return [ModelCheck::Finding] an `:error` finding
|
|
147
|
+
def finding(kind, subject, message)
|
|
148
|
+
ModelCheck::Finding.new(kind: kind, severity: :error, subject: subject, message: message)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "hecks/vocabulary"
|
|
2
|
+
require_relative "model_check/client_profile"
|
|
2
3
|
|
|
3
4
|
module Hecks
|
|
4
5
|
module Bluebook
|
|
@@ -100,6 +101,9 @@ module Hecks
|
|
|
100
101
|
# was needed or made.
|
|
101
102
|
}.freeze
|
|
102
103
|
|
|
104
|
+
# The profiles `call` accepts; `nil` is the default, unprofiled run.
|
|
105
|
+
PROFILES = %i[client].freeze
|
|
106
|
+
|
|
103
107
|
module_function
|
|
104
108
|
|
|
105
109
|
# Runs every static model check over `bluebook` and returns what it
|
|
@@ -137,9 +141,23 @@ module Hecks
|
|
|
137
141
|
# target, raising `rust_reserved_name` findings to error
|
|
138
142
|
# @param strict [Boolean] whether to raise every `rust_reserved_name`
|
|
139
143
|
# finding to error regardless of `rust_target`
|
|
144
|
+
# @param profile [Symbol, nil] `:client` adds `ClientProfile`'s error
|
|
145
|
+
# findings for constructs known to answer wrongly without refusing;
|
|
146
|
+
# `nil` (the default) adds nothing and leaves every other finding as
|
|
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`
|
|
140
152
|
# @return [Array<Finding>] every finding this bluebook triggers,
|
|
141
153
|
# across its lifecycles, sagas, policies, and Rust-reserved names
|
|
142
|
-
|
|
154
|
+
# @raise [ArgumentError] if `profile` is neither `nil` nor `:client`
|
|
155
|
+
def call(bluebook, hecksagon: nil, known_domains: nil, global_emitted_events: nil, rust_target: false, strict: false,
|
|
156
|
+
profile: nil, translations: [])
|
|
157
|
+
unless profile.nil? || PROFILES.include?(profile)
|
|
158
|
+
raise ArgumentError, "unknown profile #{profile.inspect} (known: #{PROFILES.inspect})"
|
|
159
|
+
end
|
|
160
|
+
|
|
143
161
|
findings = []
|
|
144
162
|
bluebook.aggregates.each do |aggregate|
|
|
145
163
|
findings.concat(lifecycle_findings(aggregate, aggregate))
|
|
@@ -152,6 +170,7 @@ module Hecks
|
|
|
152
170
|
findings.concat(rust_reserved_name_findings(domain_name: bluebook.name,
|
|
153
171
|
aggregate_names: bluebook.aggregates.map(&:hecks_name),
|
|
154
172
|
rust_target: rust_target, strict: strict))
|
|
173
|
+
findings.concat(ClientProfile.call(bluebook, hecksagon: hecksagon, translations: translations)) if profile == :client
|
|
155
174
|
findings
|
|
156
175
|
end
|
|
157
176
|
|
|
@@ -93,13 +93,17 @@ module Hecks
|
|
|
93
93
|
# `adapter: :postgres_era`; ignored by every other adapter
|
|
94
94
|
# @param schema [String, nil] the schema name, required only for
|
|
95
95
|
# `adapter: :postgres_era`; ignored by every other adapter
|
|
96
|
+
# @param scratch [Hash{Symbol => String}] `:database` and/or `:schema` for
|
|
97
|
+
# `adapter: :postgres` to use instead of the shared fuzz scratch schema, so a caller
|
|
98
|
+
# such as `bin/bench` never drops the schema a concurrent `bin/fuzz` is using;
|
|
99
|
+
# ignored by every other adapter
|
|
96
100
|
# @yield [String] the freshly rebound copy's root directory, ready to boot
|
|
97
101
|
# @yieldreturn [Object] anything; becomes this method's own return value
|
|
98
102
|
# @return [Object] whatever the given block returns
|
|
99
103
|
# @raise [ArgumentError] if `adapter` is not `:memory`, `:sqlite`, `:postgres`, or
|
|
100
104
|
# `:postgres_era`, or if `adapter: :postgres_era` is given without both
|
|
101
105
|
# `database:` and `schema:`
|
|
102
|
-
def call(domain_path, adapter: :memory, database: nil, schema: nil)
|
|
106
|
+
def call(domain_path, adapter: :memory, database: nil, schema: nil, scratch: {})
|
|
103
107
|
Dir.mktmpdir("hecks-fuzz") do |tmp|
|
|
104
108
|
copy = File.join(tmp, File.basename(domain_path))
|
|
105
109
|
copy_dereferencing(domain_path, copy)
|
|
@@ -107,7 +111,7 @@ module Hecks
|
|
|
107
111
|
case adapter
|
|
108
112
|
when :memory then rebind_to_memory!(copy)
|
|
109
113
|
when :sqlite then rebind_to_sqlite!(copy)
|
|
110
|
-
when :postgres then rebind_to_postgres!(copy)
|
|
114
|
+
when :postgres then rebind_to_postgres!(copy, **scratch)
|
|
111
115
|
when :postgres_era then rebind_to_postgres_era!(copy, database: database, schema: schema)
|
|
112
116
|
else raise ArgumentError,
|
|
113
117
|
"unknown fuzz adapter #{adapter.inspect} — :memory, :sqlite, :postgres, or :postgres_era"
|
|
@@ -262,15 +266,20 @@ module Hecks
|
|
|
262
266
|
FUZZ_POSTGRES_SCHEMA = "hecks_fuzz".freeze
|
|
263
267
|
|
|
264
268
|
# Rewrites every `.hecksagon` in the copy to bind through Postgres, against the
|
|
265
|
-
# shared `FUZZ_POSTGRES_DATABASE`/`FUZZ_POSTGRES_SCHEMA` scratch schema
|
|
269
|
+
# shared `FUZZ_POSTGRES_DATABASE`/`FUZZ_POSTGRES_SCHEMA` scratch schema unless the
|
|
270
|
+
# caller names its own, which lets `bin/bench` run without touching the fuzzer's.
|
|
266
271
|
#
|
|
267
272
|
# @param copy [String] the isolated copy's root directory
|
|
273
|
+
# @param database [String, nil] the scratch database, `FUZZ_POSTGRES_DATABASE` when nil
|
|
274
|
+
# @param schema [String, nil] the scratch schema, `FUZZ_POSTGRES_SCHEMA` when nil
|
|
268
275
|
# @return [void]
|
|
269
|
-
def rebind_to_postgres!(copy)
|
|
276
|
+
def rebind_to_postgres!(copy, database: nil, schema: nil)
|
|
270
277
|
require "pg"
|
|
278
|
+
database ||= FUZZ_POSTGRES_DATABASE
|
|
279
|
+
schema ||= FUZZ_POSTGRES_SCHEMA
|
|
271
280
|
rewrite_bindings!(copy, "Postgres")
|
|
272
281
|
strip_translations!(copy)
|
|
273
|
-
ensure_fuzz_schema!
|
|
282
|
+
ensure_fuzz_schema!(database, schema)
|
|
274
283
|
|
|
275
284
|
# One `.world` per directory a `.hecksagon` actually lives in, not
|
|
276
285
|
# one at `copy`'s own root — `Folder#load_domain` resolves a
|
|
@@ -278,34 +287,17 @@ module Hecks
|
|
|
278
287
|
# recursively (`Folder#load_each`); a domain can hold several
|
|
279
288
|
# `Hecks.hecksagon "<Name>" do ... end` sibling blocks in that one
|
|
280
289
|
# file (banking.hecksagon declares "Banking", "Governance", and
|
|
281
|
-
# "Identity" together), so every name found in one
|
|
282
|
-
#
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
File.write(world_path, names.map do |name|
|
|
290
|
-
<<~WORLD
|
|
291
|
-
Hecks.world "#{name}" do
|
|
292
|
-
persisted_by("Postgres") do
|
|
293
|
-
database "#{FUZZ_POSTGRES_DATABASE}"
|
|
294
|
-
schema "#{FUZZ_POSTGRES_SCHEMA}"
|
|
295
|
-
end
|
|
290
|
+
# "Identity" together), so every name found in one directory
|
|
291
|
+
# is bundled into one `.world` written there, not scattered by name.
|
|
292
|
+
write_worlds!(copy, "hecks_fuzz_postgres.world") do |name|
|
|
293
|
+
<<~WORLD
|
|
294
|
+
Hecks.world "#{name}" do
|
|
295
|
+
persisted_by("Postgres") do
|
|
296
|
+
database "#{database}"
|
|
297
|
+
schema "#{schema}"
|
|
296
298
|
end
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
end
|
|
300
|
-
|
|
301
|
-
# Any pre-existing `.world` this copy shipped with (a real
|
|
302
|
-
# deployment's own connection string) is now redundant with — and
|
|
303
|
-
# would conflict with, `Registry#add_world`'s own header on
|
|
304
|
-
# loading the same domain name twice — the fresh one just
|
|
305
|
-
# written above, so it goes the same way `rebind_to_memory!`
|
|
306
|
-
# already sends every `.world` in a Memory-mode boot.
|
|
307
|
-
Dir.glob(File.join(copy, "**", "*.world")).each do |path|
|
|
308
|
-
File.delete(path) unless File.basename(path) == "hecks_fuzz_postgres.world"
|
|
299
|
+
end
|
|
300
|
+
WORLD
|
|
309
301
|
end
|
|
310
302
|
end
|
|
311
303
|
|
|
@@ -318,8 +310,10 @@ module Hecks
|
|
|
318
310
|
# recreated on every call, which is what actually isolates one
|
|
319
311
|
# ephemeral boot's data from the next.
|
|
320
312
|
#
|
|
313
|
+
# @param database [String] the scratch database to create if missing
|
|
314
|
+
# @param schema [String] the schema to drop and recreate inside it
|
|
321
315
|
# @return [void]
|
|
322
|
-
def ensure_fuzz_schema!
|
|
316
|
+
def ensure_fuzz_schema!(database = FUZZ_POSTGRES_DATABASE, schema = FUZZ_POSTGRES_SCHEMA)
|
|
323
317
|
# `Adapters::Postgres#initialize` opens one real `PG::Connection`
|
|
324
318
|
# per aggregate and never explicitly closes it — fine for a
|
|
325
319
|
# process that boots once and runs, exactly what every other
|
|
@@ -342,17 +336,18 @@ module Hecks
|
|
|
342
336
|
# something to route around by connecting less carefully.
|
|
343
337
|
GC.start
|
|
344
338
|
|
|
345
|
-
|
|
339
|
+
@fuzz_databases_ready ||= {}
|
|
340
|
+
unless @fuzz_databases_ready[database]
|
|
346
341
|
admin = PG.connect(dbname: "postgres")
|
|
347
342
|
exists = admin.exec_params(
|
|
348
|
-
"SELECT 1 FROM pg_database WHERE datname = $1", [
|
|
343
|
+
"SELECT 1 FROM pg_database WHERE datname = $1", [database]
|
|
349
344
|
).ntuples.positive?
|
|
350
|
-
admin.exec(%(CREATE DATABASE "#{
|
|
345
|
+
admin.exec(%(CREATE DATABASE "#{database}")) unless exists
|
|
351
346
|
admin.close
|
|
352
|
-
@
|
|
347
|
+
@fuzz_databases_ready[database] = true
|
|
353
348
|
end
|
|
354
349
|
|
|
355
|
-
db = PG.connect(dbname:
|
|
350
|
+
db = PG.connect(dbname: database)
|
|
356
351
|
# **Quiet on purpose** — same as `Adapters::Postgres.connect_for`'s
|
|
357
352
|
# own `SET client_min_messages`: a `DROP SCHEMA ... CASCADE` that
|
|
358
353
|
# actually has something to drop (every boot after the first)
|
|
@@ -361,7 +356,7 @@ module Hecks
|
|
|
361
356
|
# output under a wall of "drop cascades to table ..." on every
|
|
362
357
|
# single ephemeral boot.
|
|
363
358
|
db.exec("SET client_min_messages = warning")
|
|
364
|
-
quoted = db.quote_ident(
|
|
359
|
+
quoted = db.quote_ident(schema)
|
|
365
360
|
db.exec("DROP SCHEMA IF EXISTS #{quoted} CASCADE")
|
|
366
361
|
db.exec("CREATE SCHEMA #{quoted}")
|
|
367
362
|
db.close
|
|
@@ -416,9 +411,9 @@ module Hecks
|
|
|
416
411
|
ensure_postgres_era_schema!(database: database, schema: schema)
|
|
417
412
|
|
|
418
413
|
# Same one-`.world`-per-`.hecksagon`-directory shape `rebind_to_
|
|
419
|
-
# postgres!`
|
|
420
|
-
# load_domain` globs `*.world` non-recursively) — see
|
|
421
|
-
#
|
|
414
|
+
# postgres!` uses, for the identical reason (`Folder#
|
|
415
|
+
# load_domain` globs `*.world` non-recursively) — see
|
|
416
|
+
# `write_worlds!`. `PostgresEra`
|
|
422
417
|
# additionally takes `schema:` (postgres_era.rb's own "shared-
|
|
423
418
|
# instance isolation" comment): the caller-supplied throwaway
|
|
424
419
|
# schema is what actually isolates this one ephemeral boot from
|
|
@@ -444,36 +439,54 @@ module Hecks
|
|
|
444
439
|
# database it is about to throw away; the one-line warning
|
|
445
440
|
# PostgresEra prints per boot under the opt-in is the honest
|
|
446
441
|
# price. Inert on a machine whose ambient user is ordinary.
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
442
|
+
write_worlds!(copy, "hecks_fuzz_postgres_era.world") do |name|
|
|
443
|
+
<<~WORLD
|
|
444
|
+
Hecks.world "#{name}" do
|
|
445
|
+
persisted_by("PostgresEra") do
|
|
446
|
+
database "#{database}"
|
|
447
|
+
schema "#{schema}"
|
|
448
|
+
allow_superuser true
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
WORLD
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# Writes one `.world` file per directory that holds a `.hecksagon`, naming every
|
|
456
|
+
# `Hecks.hecksagon` block found in that directory, and deletes every other `.world`
|
|
457
|
+
# in the copy.
|
|
458
|
+
#
|
|
459
|
+
# One world file per directory, every hecksagon name in that directory — never one
|
|
460
|
+
# write per `*.hecksagon` file. A domain's `context_map.hecksagon` sits beside its
|
|
461
|
+
# main hecksagon (the `qa` domain declares Governance in one and QualityControl in
|
|
462
|
+
# the other), and a second `File.write` to the same world path would drop the first
|
|
463
|
+
# file's names, leaving that domain bound to Postgres with no `database` to open.
|
|
464
|
+
#
|
|
465
|
+
# Any `.world` the copy shipped with (a real deployment's own connection string) is
|
|
466
|
+
# redundant with, and would conflict with (`Registry#add_world`'s own header, on
|
|
467
|
+
# loading the same domain name twice), the fresh one written here, so it goes the same
|
|
468
|
+
# way `rebind_to_memory!` sends every `.world` in a Memory-mode boot.
|
|
469
|
+
#
|
|
470
|
+
# @param copy [String] the isolated copy's root directory
|
|
471
|
+
# @param world_file [String] the basename of the world file written in each directory
|
|
472
|
+
# @yield [name] builds the world text for one hecksagon name
|
|
473
|
+
# @yieldparam name [String] a domain name a `Hecks.hecksagon` block in the directory declares
|
|
474
|
+
# @yieldreturn [String] the `Hecks.world` block for that name
|
|
475
|
+
# @return [void]
|
|
476
|
+
def write_worlds!(copy, world_file, &world_for)
|
|
477
|
+
names_by_dir = Hash.new { |hash, dir| hash[dir] = [] }
|
|
453
478
|
Dir.glob(File.join(copy, "**", "*.hecksagon")).each do |hecksagon_path|
|
|
454
479
|
names = File.read(hecksagon_path).scan(/Hecks\.hecksagon\s+"([^"]+)"/).flatten
|
|
455
|
-
|
|
480
|
+
names_by_dir[File.dirname(hecksagon_path)].concat(names)
|
|
456
481
|
end
|
|
457
|
-
|
|
458
|
-
names = names.uniq
|
|
482
|
+
names_by_dir.each do |dir, names|
|
|
459
483
|
next if names.empty?
|
|
460
484
|
|
|
461
|
-
|
|
462
|
-
File.write(world_path, names.map do |name|
|
|
463
|
-
<<~WORLD
|
|
464
|
-
Hecks.world "#{name}" do
|
|
465
|
-
persisted_by("PostgresEra") do
|
|
466
|
-
database "#{database}"
|
|
467
|
-
schema "#{schema}"
|
|
468
|
-
allow_superuser true
|
|
469
|
-
end
|
|
470
|
-
end
|
|
471
|
-
WORLD
|
|
472
|
-
end.join("\n"))
|
|
485
|
+
File.write(File.join(dir, world_file), names.uniq.map(&world_for).join("\n"))
|
|
473
486
|
end
|
|
474
487
|
|
|
475
488
|
Dir.glob(File.join(copy, "**", "*.world")).each do |path|
|
|
476
|
-
File.delete(path) unless File.basename(path) ==
|
|
489
|
+
File.delete(path) unless File.basename(path) == world_file
|
|
477
490
|
end
|
|
478
491
|
end
|
|
479
492
|
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
require "socket"
|
|
2
|
+
|
|
3
|
+
module Hecks
|
|
4
|
+
# The startup gate for the stdio MCP servers in `bin/` (`hecks_mcp_door`,
|
|
5
|
+
# `hecks_query_ir_mcp`): it refuses to run when the process was set up to
|
|
6
|
+
# speak to anything but a local parent, and it says on stderr what the
|
|
7
|
+
# process does and does not protect.
|
|
8
|
+
#
|
|
9
|
+
# ## Why a gate and not a comment
|
|
10
|
+
#
|
|
11
|
+
# Both servers have no authentication. What keeps a stranger away from
|
|
12
|
+
# them is that only a process that can spawn them can write to their
|
|
13
|
+
# stdin. That holds until someone wraps one in `socat TCP-LISTEN:... EXEC:`
|
|
14
|
+
# or an `inetd`-style listener, which hands the process a network socket
|
|
15
|
+
# as stdin/stdout without changing a line of the server. The gate turns
|
|
16
|
+
# that setup into a refusal at startup instead of an exposure nobody
|
|
17
|
+
# noticed.
|
|
18
|
+
#
|
|
19
|
+
# ## What it checks
|
|
20
|
+
#
|
|
21
|
+
# - **Arguments** — none, except `--stdio`. A `--port` or `--http` flag is
|
|
22
|
+
# refused rather than ignored, so a caller that believes it started a
|
|
23
|
+
# network server finds out immediately.
|
|
24
|
+
# - **Environment** — no `HECKS_MCP_*` variable except
|
|
25
|
+
# `HECKS_MCP_TRANSPORT=stdio`. The servers take no configuration, so an
|
|
26
|
+
# unknown option in that namespace fails closed.
|
|
27
|
+
# - **Descriptors** — stdin and stdout must not be internet-protocol sockets
|
|
28
|
+
# (`AF_INET` or `AF_INET6`). A pipe, a file, a terminal and a Unix-domain socket all pass;
|
|
29
|
+
# the last matters because some MCP clients spawn servers over a
|
|
30
|
+
# `socketpair`, which is local to the machine.
|
|
31
|
+
#
|
|
32
|
+
# ## What it does not do
|
|
33
|
+
#
|
|
34
|
+
# It does not authenticate anyone and it cannot see a proxy that copies
|
|
35
|
+
# bytes from a network socket into an ordinary pipe. It closes the
|
|
36
|
+
# configurations that announce themselves; the ADR on MCP authentication
|
|
37
|
+
# (`docs/decisions/0062-mcp-servers-need-real-authentication-before-any-network-transport.md`)
|
|
38
|
+
# covers what a network transport would need.
|
|
39
|
+
module McpStdioGuard
|
|
40
|
+
ACCEPTED_ARGS = %w[--stdio].freeze
|
|
41
|
+
ENV_PREFIX = "HECKS_MCP_".freeze
|
|
42
|
+
ACCEPTED_ENV = { "HECKS_MCP_TRANSPORT" => "stdio" }.freeze
|
|
43
|
+
EXIT_STATUS = 2
|
|
44
|
+
|
|
45
|
+
# The lines every stdio server prints, ahead of its own notes.
|
|
46
|
+
COMMON_NOTES = [
|
|
47
|
+
"stdio only. This server has no authentication and must not be exposed over a network.",
|
|
48
|
+
"Whoever can write to this process's stdin can make every call it accepts."
|
|
49
|
+
].freeze
|
|
50
|
+
|
|
51
|
+
module_function
|
|
52
|
+
|
|
53
|
+
# Lists every reason this process should not start as a stdio server.
|
|
54
|
+
#
|
|
55
|
+
# @param argv [Array<String>] the command-line arguments
|
|
56
|
+
# @param env [Hash{String => String}] the process environment
|
|
57
|
+
# @param stdin [IO] the stream requests arrive on
|
|
58
|
+
# @param stdout [IO] the stream responses leave on
|
|
59
|
+
# @return [Array<String>] one message per violation; empty when the process is
|
|
60
|
+
# set up for stdio
|
|
61
|
+
def violations(argv: ARGV, env: ENV, stdin: $stdin, stdout: $stdout)
|
|
62
|
+
argument_violations(argv) + environment_violations(env) + descriptor_violations(stdin, stdout)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Builds the warning printed at startup.
|
|
66
|
+
#
|
|
67
|
+
# @param server [String] the server's name, used as the line prefix
|
|
68
|
+
# @param notes [Array<String>] server-specific lines added after `COMMON_NOTES`
|
|
69
|
+
# @return [Array<String>] the banner lines, each prefixed with `server`
|
|
70
|
+
def banner(server:, notes: [])
|
|
71
|
+
(COMMON_NOTES + notes).map { |line| "#{server}: #{line}" }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Refuses to continue on a non-stdio setup.
|
|
75
|
+
#
|
|
76
|
+
# A server calls this before it requires anything heavy, so a misconfigured start
|
|
77
|
+
# fails in milliseconds and before any protocol byte is answered. Everything goes
|
|
78
|
+
# to `stderr`: stdout carries the MCP protocol, and one stray line there corrupts
|
|
79
|
+
# the client's framing.
|
|
80
|
+
#
|
|
81
|
+
# @param server [String] the server's name, used as the line prefix
|
|
82
|
+
# @param argv [Array<String>] the command-line arguments
|
|
83
|
+
# @param env [Hash{String => String}] the process environment
|
|
84
|
+
# @param stdin [IO] the stream requests arrive on
|
|
85
|
+
# @param stdout [IO] the stream responses leave on
|
|
86
|
+
# @param stderr [IO] where the refusal is written
|
|
87
|
+
# @return [void]
|
|
88
|
+
# @raise [SystemExit] with status `EXIT_STATUS` when `violations` is not empty
|
|
89
|
+
def enforce_stdio!(server:, argv: ARGV, env: ENV, stdin: $stdin, stdout: $stdout, stderr: $stderr)
|
|
90
|
+
found = violations(argv: argv, env: env, stdin: stdin, stdout: stdout)
|
|
91
|
+
refuse!(server, found, stderr) unless found.empty?
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Prints the startup warning to `stderr`, never to stdout.
|
|
95
|
+
#
|
|
96
|
+
# @param server [String] the server's name, used as the line prefix
|
|
97
|
+
# @param notes [Array<String>] server-specific warning lines
|
|
98
|
+
# @param stderr [IO] where the warning is written
|
|
99
|
+
# @return [void]
|
|
100
|
+
def warn!(server:, notes: [], stderr: $stderr)
|
|
101
|
+
banner(server: server, notes: notes).each { |line| stderr.puts(line) }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Enforces stdio, then prints the warning.
|
|
105
|
+
#
|
|
106
|
+
# @param server [String] the server's name, used as the line prefix
|
|
107
|
+
# @param notes [Array<String>] server-specific warning lines
|
|
108
|
+
# @param stderr [IO] where the refusal or the warning is written
|
|
109
|
+
# @param checks [Hash{Symbol => Object}] `argv:`, `env:`, `stdin:` and `stdout:`, as
|
|
110
|
+
# for `enforce_stdio!`
|
|
111
|
+
# @return [void]
|
|
112
|
+
# @raise [SystemExit] with status `EXIT_STATUS` when the setup is not stdio
|
|
113
|
+
def start!(server:, notes: [], stderr: $stderr, **checks)
|
|
114
|
+
enforce_stdio!(server: server, stderr: stderr, **checks)
|
|
115
|
+
warn!(server: server, notes: notes, stderr: stderr)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# @api private
|
|
119
|
+
def refuse!(server, found, stderr)
|
|
120
|
+
found.each { |message| stderr.puts("#{server}: refusing to start: #{message}") }
|
|
121
|
+
stderr.puts("#{server}: this server speaks MCP over stdio only.")
|
|
122
|
+
exit(EXIT_STATUS)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# @api private
|
|
126
|
+
def argument_violations(argv)
|
|
127
|
+
(argv - ACCEPTED_ARGS).map do |arg|
|
|
128
|
+
"argument #{arg.inspect} is not accepted (only #{ACCEPTED_ARGS.join(', ')}); network options are refused, not ignored"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @api private
|
|
133
|
+
def environment_violations(env)
|
|
134
|
+
env.select { |name, value| name.start_with?(ENV_PREFIX) && ACCEPTED_ENV[name] != value }.map do |name, value|
|
|
135
|
+
"environment #{name}=#{value.inspect} is not accepted; the servers take no #{ENV_PREFIX}* options " \
|
|
136
|
+
"except #{ACCEPTED_ENV.map { |k, v| "#{k}=#{v}" }.join(', ')}"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @api private
|
|
141
|
+
def descriptor_violations(stdin, stdout)
|
|
142
|
+
{ "stdin" => stdin, "stdout" => stdout }.select { |_, io| ip_socket?(io) }.map do |label, _|
|
|
143
|
+
"#{label} is a network (IP) socket, so this process is being served over a network"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# A socket whose address cannot be read is refused: it cannot be shown to be local.
|
|
148
|
+
#
|
|
149
|
+
# @api private
|
|
150
|
+
def ip_socket?(io)
|
|
151
|
+
return false unless io.stat.socket?
|
|
152
|
+
|
|
153
|
+
socket = Socket.for_fd(io.fileno)
|
|
154
|
+
socket.autoclose = false # the descriptor belongs to `io`
|
|
155
|
+
socket.local_address.ip?
|
|
156
|
+
rescue SystemCallError, IOError
|
|
157
|
+
true
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
data/lib/hecks/version.rb
CHANGED
data/lib/hecks.rb
CHANGED
|
@@ -31,6 +31,7 @@ require_relative "hecks/projections"
|
|
|
31
31
|
require_relative "hecks/facade/cli_door"
|
|
32
32
|
require_relative "hecks/facade/cli_runner"
|
|
33
33
|
require_relative "hecks/storehouse"
|
|
34
|
+
require_relative "hecks/mcp_stdio_guard"
|
|
34
35
|
require_relative "hecks/framework"
|
|
35
36
|
require_relative "hecks/corpus"
|
|
36
37
|
require_relative "hecks/embryonaut_bluebook"
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hecks
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Chris Young
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-27 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: prism
|
|
@@ -94,6 +94,17 @@ files:
|
|
|
94
94
|
- lib/hecks/behaviors/ir.rb
|
|
95
95
|
- lib/hecks/behaviors/rspec.rb
|
|
96
96
|
- lib/hecks/behaviors/runner.rb
|
|
97
|
+
- lib/hecks/bench.rb
|
|
98
|
+
- lib/hecks/bench/cli.rb
|
|
99
|
+
- lib/hecks/bench/environment.rb
|
|
100
|
+
- lib/hecks/bench/postgres_probe.rb
|
|
101
|
+
- lib/hecks/bench/report.rb
|
|
102
|
+
- lib/hecks/bench/ruby_runner.rb
|
|
103
|
+
- lib/hecks/bench/run.rb
|
|
104
|
+
- lib/hecks/bench/rust_runner.rb
|
|
105
|
+
- lib/hecks/bench/stats.rb
|
|
106
|
+
- lib/hecks/bench/suite.rb
|
|
107
|
+
- lib/hecks/bench/workload.rb
|
|
97
108
|
- lib/hecks/bluebook.rb
|
|
98
109
|
- lib/hecks/bluebook/aggregate.rb
|
|
99
110
|
- lib/hecks/bluebook/assembly.rb
|
|
@@ -174,6 +185,7 @@ files:
|
|
|
174
185
|
- lib/hecks/bluebook/meta_validator/translation_judge.rb
|
|
175
186
|
- lib/hecks/bluebook/meta_validator/world_judge.rb
|
|
176
187
|
- lib/hecks/bluebook/model_check.rb
|
|
188
|
+
- lib/hecks/bluebook/model_check/client_profile.rb
|
|
177
189
|
- lib/hecks/bluebook/pattern_subset.rb
|
|
178
190
|
- lib/hecks/bluebook/policy.rb
|
|
179
191
|
- lib/hecks/bluebook/process_manager.rb
|
|
@@ -302,6 +314,7 @@ files:
|
|
|
302
314
|
- lib/hecks/language/world/wiring.bluebook
|
|
303
315
|
- lib/hecks/language/world/world.bluebook
|
|
304
316
|
- lib/hecks/literal.rb
|
|
317
|
+
- lib/hecks/mcp_stdio_guard.rb
|
|
305
318
|
- lib/hecks/naming.rb
|
|
306
319
|
- lib/hecks/ports.rb
|
|
307
320
|
- lib/hecks/ports/access_control.port
|